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

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

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

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

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

  • 押下
  • カーソルを合わせた回数
  • 選択済み
  • 有効
  • 切り替え

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

スタイル パラメータでインタラクション状態を処理する

次の例は、インタラクションの状態に応じて 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 = remember(interactionSource) { MutableStyleState(interactionSource) }
    styleState.isEnabled = enabled
    Row(
        modifier =
            modifier
                .clickable(
                    onClick = onClick,
                    enabled = enabled,
                    interactionSource = interactionSource,
                    indication = null,
                )
                .styleable(styleState, baseGradientButtonStyle then style),
        content = content,
    )
}

interactionSource 状態を使用して、スタイル ブロック内の押下、フォーカス、ホバーのオプションでスタイルの変更を制御できるようになりました。

@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)
}