スタイルでの状態とアニメーション

Styles API は、hoveredfocusedpressed などのインタラクション状態での UI の変更を管理するための、宣言型で合理化されたアプローチを提供します。この API を使用すると、修飾子を使用する場合に通常必要となるボイラープレート コードを大幅に削減できます。

リアクティブ スタイリングを容易にするため、StyleState は、要素のアクティブな状態(有効、押下、フォーカスなど)を追跡する、安定した読み取り専用のインターフェースとして機能します。StyleScope 内では、state プロパティを介してこれにアクセスし、Style 定義に条件付きロジックを直接実装できます。

状態ベースのインタラクション: ホバー、フォーカス、押下、選択、有効、切り替え

スタイルには、一般的なインタラクションのサポートが組み込まれています。

  • 押下
  • ホバー
  • 選択
  • 有効
  • 切り替え

カスタム状態をサポートすることもできます。詳細については、StyleState を使用した カスタム状態のスタイリングをご覧ください。

Style パラメータを使用してインタラクション状態を処理する

次の例は、インタラクション状態に応じて backgroundborderColor を変更する方法を示しています。具体的には、ホバーすると紫に、フォーカスすると青に切り替わります。

@Preview
@Composable
private fun OpenButton() {
    BaseButton(
        style = outlinedButtonStyle then {
            background(Color.White)
            hovered {
                background(lightPurple)
                border(2.dp, lightPurple)
            }
            focused {
                background(lightBlue)
            }
        },
        onClick = {  },
        content = {
            BaseText("Open in Studio", style = {
                contentColor(Color.Black)
                fontSize(26.sp)
                textAlign(TextAlign.Center)
            })
        }
    )
}

図 1. ホバー状態とフォーカス状態に基づいて背景色を変更する。

ネストされた状態定義を作成することもできます。たとえば、ボタンが同時に押下され、ホバーされている場合の特定のスタイルを定義できます。

@Composable
private fun OpenButton_CombinedStates() {
    BaseButton(
        style = outlinedButtonStyle then {
            background(Color.White)
            hovered {
                // light purple
                background(lightPurple)
                pressed {
                    // When running on a device that can hover, whilst hovering and then pressing the button this would be invoked
                    background(lightOrange)
                }
            }
            pressed {
                // when running on a device without a mouse attached, this would be invoked as you wouldn't be in a hovered state only
                background(lightRed)
            }
            focused {
                background(lightBlue)
            }
        },
        onClick = {  },
        content = {
            BaseText("Open in Studio", style = {
                contentColor(Color.Black)
                fontSize(26.sp)
                textAlign(TextAlign.Center)
            })
        }
    )
}

図 2. ボタンでホバー状態と押下状態を同時に表示する。

Modifier.styleable を使用したカスタム コンポーザブル

独自の styleable コンポーネントを作成する場合は、interactionSourcestyleState に接続する必要があります。次に、この状態を Modifier.styleable に渡して使用します。

デザイン システムに GradientButton が含まれているシナリオを考えてみましょう。GradientButton から継承する LoginButton を作成し、押下などのインタラクション中に色を変更したいとします。

  • interactionSource スタイルの更新を有効にするには、コンポーザブル内に interactionSource をパラメータとして含めます。指定されたパラメータを使用します。指定されていない場合は、新しい MutableInteractionSource を初期化します。
  • interactionSource を指定して styleState を初期化します。styleState の有効ステータスが、指定された有効パラメータの値を反映していることを確認します。
  • interactionSourcefocusable 修飾子と clickable 修飾子に割り当てます。最後に、styleState を修飾子の styleable パラメータに適用します。

@Composable
private fun GradientButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    style: Style = Style,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource? = null,
    content: @Composable RowScope.() -> Unit,
) {
    val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
    val styleState = rememberUpdatedStyleState(interactionSource) {
        it.isEnabled = enabled
    }
    Row(
        modifier =
            modifier
                .clickable(
                    onClick = onClick,
                    enabled = enabled,
                    interactionSource = interactionSource,
                    indication = null,
                )
                .styleable(styleState, baseGradientButtonStyle then style),
        content = content,
    )
}

これで、interactionSource 状態を使用して、スタイル ブロック内の pressed、focused、hovered オプションでスタイルの変更を制御できます。

@Preview
@Composable
fun LoginButton() {
    val loginButtonStyle = Style {
        pressed {
            background(
                Brush.linearGradient(
                    listOf(Color.Magenta, Color.Red)
                )
            )
        }
    }
    GradientButton(onClick = {
        // Login logic
    }, style = loginButtonStyle) {
        BaseText("Login")
    }
}

図 3. interactionSource に基づいてカスタム コンポーザブルの状態を変更する。

スタイルの変更をアニメーション化する

スタイルの状態変更には、アニメーションのサポートが組み込まれています。新しいプロパティを animate を使用して状態変更ブロック内にラップすると、異なる状態間のアニメーションが自動的に追加されます。これは animate*AsState API と似ています。次の例では、状態がフォーカスに変わると、borderColor が黒から青にアニメーション化されます。

val animatingStyle = Style {
    externalPadding(48.dp)
    border(3.dp, Color.Black)
    background(Color.White)
    size(100.dp)

    pressed {
        animate {
            borderColor(Color.Magenta)
            background(Color(0xFFB39DDB))
        }
    }
}

@Preview
@Composable
private fun AnimatingStyleChanges() {
    val interactionSource = remember { MutableInteractionSource() }
    val styleState = remember(interactionSource) { MutableStyleState(interactionSource) }
    Box(modifier = Modifier
        .clickable(
            interactionSource,
            enabled = true,
            indication = null,
            onClick = {

            }
        )
        .styleable(styleState, animatingStyle)) {

    }
}

図 4. 押下時の色の変化をアニメーション化する。

animate API は、アニメーション カーブの期間や形状を変更する animationSpec を受け入れます。次の例では、spring 仕様を使用してボックスのサイズをアニメーション化します。

val animatingStyleSpec = Style {
    externalPadding(48.dp)
    border(3.dp, Color.Black)
    background(Color.White)
    size(100.dp)
    transformOrigin(TransformOrigin.Center)
    pressed {
        animate {
            borderColor(Color.Magenta)
            background(Color(0xFFB39DDB))
        }
        animate(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) {
            scale(1.2f)
        }
    }
}

@Preview(showBackground = true)
@Composable
fun AnimatingStyleChangesSpec() {
    val interactionSource = remember { MutableInteractionSource() }
    val styleState = remember(interactionSource) { MutableStyleState(interactionSource) }
    Box(modifier = Modifier
        .clickable(
            interactionSource,
            enabled = true,
            indication = null,
            onClick = {

            }
        )
        .styleable(styleState, animatingStyleSpec))
}

図 5. 押下時のサイズと色の変化をアニメーション化する。

StyleState を使用したカスタム状態のスタイリング

コンポーザブルのユースケースによっては、カスタム状態に裏打ちされたさまざまなスタイルが必要になる場合があります。たとえば、メディアアプリの場合、プレーヤーの再生状態に応じて、MediaPlayer コンポーザブルのボタンのスタイルを変更したいとします。独自のカスタム状態を作成して使用するには、次の手順を行います。

  1. カスタムキーを定義する
  2. StyleState 拡張機能を作成する
  3. カスタム状態にリンクする

カスタムキーを定義する

カスタム状態ベースのスタイルを作成するには、まず StyleStateKeyを作成し、デフォルトの状態値を渡します。アプリの起動時、メディア プレーヤーは Stopped 状態であるため、次のように初期化されます。

enum class PlayerState {
    Stopped,
    Playing,
    Paused
}

val playerStateKey = StyleStateKey(PlayerState.Stopped)

StyleState 拡張関数を作成する

StyleState に拡張関数を定義して、現在の playState をクエリします。次に、カスタム状態を使用して StyleScope に拡張関数を作成し、playStateKey、特定の状態のラムダ、スタイルを渡します。

// Extension Function on MutableStyleState to query and set the current playState
var MutableStyleState.playerState
    get() = this[playerStateKey]
    set(value) { this[playerStateKey] = value }

fun StyleScope.playerPlaying(value: Style) {
    state(playerStateKey, value, { key, state -> state[key] == PlayerState.Playing })
}
fun StyleScope.playerPaused(value: Style) {
    state(playerStateKey, value, { key, state -> state[key] == PlayerState.Paused })
}

コンポーザブルで styleState を定義し、styleState.playState を入力状態と等しく設定します。修飾子の styleable 関数に styleState を渡します。

style ラムダ内では、事前に定義した拡張関数を使用して、カスタム状態に状態ベースのスタイリングを適用できます。

次のコードは、この例の完全なスニペットです。

enum class PlayerState {
    Stopped,
    Playing,
    Paused
}
val playerStateKey = StyleStateKey<PlayerState>(PlayerState.Stopped)
var MutableStyleState.playerState
    get() = this[playerStateKey]
    set(value) { this[playerStateKey] = value }

fun StyleScope.playerPlaying(value: Style) {
    state(playerStateKey, value, { key, state -> state[key] == PlayerState.Playing })
}
fun StyleScope.playerPaused(value: Style) {
    state(playerStateKey, value, { key, state -> state[key] == PlayerState.Paused })

}

@Composable
fun MediaPlayer(
    url: String,
    modifier: Modifier = Modifier,
    style: Style = Style,
    state: PlayerState = remember { PlayerState.Paused }
) {
    // Hoist style state, set playstate as a parameter,
    val styleState = remember { MutableStyleState(null) }
    // Set equal to incoming state to link the two together
    styleState.playerState = state
    Box(
        modifier = modifier.styleable(styleState, Style {
            size(100.dp)
            border(2.dp, Color.Red)

        }, style, )) {

        ///..
    }
}
@Composable
fun StyleStateKeySample() {
    // Using the extension function to change the border color to green while playing
    val style = Style {
        borderColor(Color.Gray)
        playerPlaying {
            animate {
                borderColor(Color.Green)
            }
        }
        playerPaused {
            animate {
                borderColor(Color.Blue)
            }
        }
    }
    val styleState = remember { MutableStyleState(null) }
    styleState[playerStateKey] = PlayerState.Playing

    // Using the style in a composable that sets the state -> notice if you change the state parameter, the style changes. You can link this up to an ViewModel and change the state from there too.
    MediaPlayer(url = "https://example.com/media/video",
        style = style,
        state = PlayerState.Stopped)
}