LoginSignup
12
10

More than 1 year has passed since last update.

Jetpack Compose の Component の中身を調べてみた

Last updated at Posted at 2023-01-27

はじめに

Jetpack Compose は Android アプリの UI を作成するためのツールキットです。Kotlin で記述することでこれまでの xml で記述する方法よりも簡単に画面を作成できたりします。

例えばこれまでだと RecyclerView を使う場合など、レイアウト用の xml ファイル、RecyclerView.Adapter を継承したクラス、RecyclerView.ViewHolder を継承したクラス等が必要になってきます。

一方、 Jetpack Compose を使えば LazyColumn Composable を使えば、ほぼ RecyclerView と同じことができたりします。

また、動的に画面の構成要素を増やしたい時などは xml ベースですと結構大変ですが、Jetpack Compose を用いれば比較的簡単に実装できます。

まあ実際にはどう書くのか分からず指が止まって色々調べたりする場面も多いですが、そのうち Jetpack Compose を使うのが標準になっていき、ドキュメント等も充実してくるでしょう。

そのように、指が止まり、どうやるんだろう?と考える事の一つに独自の Component を作る場合があります。これまでだと View を継承したクラスを作り、 onSizeChanged や onDraw, onTouchEvent 等を実装して作成してきました。

Jetpack Compose ではどのように独自の Component を作成するのかを調べるために、まずは既存の Component の中身を調べてみたいと思います。具体的には Material 3 の Component で簡単そうなものから、比較的複雑そうなものまで、以下の 3つを調べてみたいと思います。

ここではあまり深追いはせず、何となく Component はこんな感じで作るんだと理解できる程度に調べていきたいと思います。

なお調査に使用した ライブラリのバージョン"androidx.compose.ui:ui:1.3.3", 'androidx.compose.material3:material3:1.0.1' 等となります。

Button

以下の様に単純な Button を考えてみます。

@Preview
@Composable
fun ButtonPreview() {
    Button(onClick = {  }) {
        Text(text = "Button")
    }
}

button_image.png

Button のソースコードを見てみましょう。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    shape: Shape = ButtonDefaults.shape,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
    border: BorderStroke? = null,
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    interactionSource: MutableInteractionSource =
        remember { MutableInteractionSource() },
    content: @Composable RowScope.() -> Unit
) {
    val containerColor = colors.containerColor(enabled).value
    val contentColor = colors.contentColor(enabled).value
    val shadowElevation = elevation?.
            shadowElevation(enabled, interactionSource)?.value ?: 0.dp
    val tonalElevation = elevation?.
            tonalElevation(enabled, interactionSource)?.value ?: 0.dp
    Surface(
        onClick = onClick,
        modifier = modifier,
        enabled = enabled,
        shape = shape,
        color = containerColor,
        contentColor = contentColor,
        tonalElevation = tonalElevation,
        shadowElevation = shadowElevation,
        border = border,
        interactionSource = interactionSource
    ) {
        CompositionLocalProvider(LocalContentColor provides contentColor) {
            ProvideTextStyle(value = MaterialTheme.typography.labelLarge) {
                Row(
                    Modifier
                        .defaultMinSize(
                            minWidth = ButtonDefaults.MinWidth,
                            minHeight = ButtonDefaults.MinHeight
                        )
                        .padding(contentPadding),
                    horizontalArrangement = Arrangement.Center,
                    verticalAlignment = Alignment.CenterVertically,
                    content = content
                )
            }
        }
    }
}

これを見ると Button の実態は Surface であることが分かります。

重要な部分を見ていきましょう。

まず Button の標準の形と色を Surface に渡しています。

fun Button(
    // ...
    shape: Shape = ButtonDefaults.shape,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    // ...
) {
    val containerColor = colors.containerColor(enabled).value
    val contentColor = colors.contentColor(enabled).value
    // ...
    Surface(
        // ...
        shape = shape,
        color = containerColor,
        contentColor = contentColor,
        // ...
    ) {

ButtonDefaults.shape はソースコードを追っていくと

RoundedCornerShape(percent = 50)

となり、左右(縦長の場合は上下)の境界が半円になるような形を指定しています。

この様に Button の色と形は Surface によって作られています。

そして Row によって Button の内部のコンテンツが横に並べられます。

Row(
    // ...
    content = content
)

また Button をタップした時の応答機能は以下の様に onClickinteractionSource を Surface に渡し委譲しています。

fun Button(
    onClick: () -> Unit,
    // ...
    interactionSource: MutableInteractionSource =
        remember { MutableInteractionSource() },
    // ...
) {
    // ...
    Surface(
        onClick = onClick,
        // ...
        interactionSource = interactionSource
    ) {

まとめると Button は以下の様な構成となっています。

  • Button の色や形は Surface を用いて作成されている。
  • Button をタップした時の応答も Surface が受け持っている。
  • Button 内のコンテンツは Row を用いて横に並べている。

この様に Button のような簡単なコンポーネントは描画機能や応答機能を独自に実装することはせず、Surface や Row のようなより単純なコンポーネントに委譲していることが分かります。

さらにソースコードを追っていくと Surface は Box を用いて実装されています。

@Composable
fun Surface(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    shape: Shape = RectangleShape,
    color: Color = MaterialTheme.colorScheme.surface,
    contentColor: Color = contentColorFor(color),
    tonalElevation: Dp = 0.dp,
    shadowElevation: Dp = 0.dp,
    border: BorderStroke? = null,
    interactionSource: MutableInteractionSource =
        remember { MutableInteractionSource() },
    content: @Composable () -> Unit
) {
    val absoluteElevation = LocalAbsoluteTonalElevation.current + tonalElevation
    CompositionLocalProvider(
        LocalContentColor provides contentColor,
        LocalAbsoluteTonalElevation provides absoluteElevation
    ) {
        Box(
            modifier = modifier
                .minimumTouchTargetSize()
                .surface(
                    shape = shape,
                    backgroundColor = surfaceColorAtElevation(
                        color = color,
                        elevation = absoluteElevation
                    ),
                    border = border,
                    shadowElevation = shadowElevation
                )
                .clickable(
                    interactionSource = interactionSource,
                    indication = rememberRipple(),
                    enabled = enabled,
                    role = Role.Button,
                    onClick = onClick
                ),
            propagateMinConstraints = true
        ) {
            content()
        }
    }
}

これを見ると Button の色や形は Box の Modifier.surface で、タップ時の応答機能は Modifier.clickable の部分で実装されているのが分かります。

Modifier.surface は Surface 独自の Modifier の拡張関数で以下の様になっています。

private fun Modifier.surface(
    shape: Shape,
    backgroundColor: Color,
    border: BorderStroke?,
    shadowElevation: Dp
) = this.shadow(shadowElevation, shape, clip = false)
    .then(if (border != null) Modifier.border(border, shape) else Modifier)
    .background(color = backgroundColor, shape = shape)
    .clip(shape)

さらに Box や Row は Layout を用いて実装されています。 Layout では機能を実装するのではなく content で渡された内部のコンポーネントを レイアウト する役目を担っています。

@Composable
inline fun Box(
    modifier: Modifier = Modifier,
    contentAlignment: Alignment = Alignment.TopStart,
    propagateMinConstraints: Boolean = false,
    content: @Composable BoxScope.() -> Unit
) {
    val measurePolicy =
        rememberBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
    Layout(
        content = { BoxScopeInstance.content() },
        measurePolicy = measurePolicy,
        modifier = modifier
    )
}

@Composable
inline fun Row(
    modifier: Modifier = Modifier,
    horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
    verticalAlignment: Alignment.Vertical = Alignment.Top,
    content: @Composable RowScope.() -> Unit
) {
    val measurePolicy = rowMeasurePolicy(horizontalArrangement, verticalAlignment)
    Layout(
        content = { RowScopeInstance.content() },
        measurePolicy = measurePolicy,
        modifier = modifier
    )
}

まとめると

  • Button の色や形は Surface 独自の Modifier 拡張関数 Modifier.surface を Box に渡して作成されている。
  • Button をタップした時の応答機能は Box の Modifier.clickable が受け持っている。
  • Button は Surface と Row を入れ子にすることで構成されている。
  • Surface は Box で構成されている。
  • Box と Row は Layout によって実装されている。

Switch

以下の様に単純な Switch を考えてみます。

@Preview
@Composable
fun SwitchPreview() {
    Switch(checked = true, onCheckedChange = {})
}

switch_image_on.png

@Preview
@Composable
fun SwitchPreview() {
    Switch(checked = false, onCheckedChange = {})
}

switch_image_off.png

Switch は Thumb と Track という 2つの部分で構成されています。 Thumb はつまみで Track 上でスライドして動作します。

Switch のソースコードを以下に示します。

@Composable
@Suppress("ComposableLambdaParameterNaming", "ComposableLambdaParameterPosition")
fun Switch(
    checked: Boolean,
    onCheckedChange: ((Boolean) -> Unit)?,
    modifier: Modifier = Modifier,
    thumbContent: (@Composable () -> Unit)? = null,
    enabled: Boolean = true,
    colors: SwitchColors = SwitchDefaults.colors(),
    interactionSource: MutableInteractionSource =
        remember { MutableInteractionSource() },
) {
    val uncheckedThumbDiameter = if (thumbContent == null) {
        UncheckedThumbDiameter
    } else {
        ThumbDiameter
    }

    val thumbPaddingStart = (SwitchHeight - uncheckedThumbDiameter) / 2
    val minBound = with(LocalDensity.current) { thumbPaddingStart.toPx() }
    val maxBound = with(LocalDensity.current) { ThumbPathLength.toPx() }
    val valueToOffset = remember<(Boolean) -> Float>(minBound, maxBound) {
        { value -> if (value) maxBound else minBound }
    }

    val targetValue = valueToOffset(checked)
    val offset = remember { Animatable(targetValue) }
    val scope = rememberCoroutineScope()

    SideEffect {
        // min bound might have changed if the icon is only rendered
        // in checked state.
        offset.updateBounds(lowerBound = minBound)
    }

    DisposableEffect(checked) {
        if (offset.targetValue != targetValue) {
            scope.launch {
                offset.animateTo(targetValue, AnimationSpec)
            }
        }
        onDispose { }
    }

    // TODO: Add Swipeable modifier b/223797571
    val toggleableModifier =
        if (onCheckedChange != null) {
            Modifier.toggleable(
                value = checked,
                onValueChange = onCheckedChange,
                enabled = enabled,
                role = Role.Switch,
                interactionSource = interactionSource,
                indication = null
            )
        } else {
            Modifier
        }

    Box(
        modifier
            .then(
                if (onCheckedChange != null) Modifier.minimumTouchTargetSize()
                else Modifier
            )
            .then(toggleableModifier)
            .wrapContentSize(Alignment.Center)
            .requiredSize(SwitchWidth, SwitchHeight)
    ) {
        SwitchImpl(
            checked = checked,
            enabled = enabled,
            colors = colors,
            thumbValue = offset.asState(),
            interactionSource = interactionSource,
            thumbShape = SwitchTokens.HandleShape.toShape(),
            uncheckedThumbDiameter = uncheckedThumbDiameter,
            minBound = thumbPaddingStart,
            maxBound = ThumbPathLength,
            thumbContent = thumbContent,
        )
    }
}

ちょっと複雑なコードになっていますが、行っていることは以下の様になります。

  • Switch の on/off 状態を表す checked 引数の値に応じて、 Thumb の位置(左側を Start とする場合 Track の左端からの位置)を計算し offset 変数に保存します(動作にアニメーションを使っているため、コードが複雑になっていますが、行っていることはこれだけです)。
  • Switch が押された時に応答を返す機能を toggleableModifier として実装します。
  • Switch を実際に描画する SwitchImpl を Box で括ります。
  • Box に先程の押された時の応答を返す機能 toggleableModifier を渡すことで委譲します。
  • SwitchImpl に on/off 状態を表す checked 、各部の色を表す colors 、 Thumb の位置を表す offset.asState() 、形を表す SwitchTokens.HandleShape.toShape() 等を渡し Track と Thumb の描画を委譲します。
  • SwitchImpl に interactionSource を渡し、 Box での押す動作の検出を SwitchImpl 内で利用できるようにします。

なお SwitchTokens.HandleShape.toShape() はソースコードを追っていくと以下の様になり左右の境界が半円になるような形を指定しています。実際には SwitchImpl の内部で幅と高さが同じになるように指定されているので、円になります。

RoundedCornerShape(percent = 50)

SwitchImpl のソースコードを見てみましょう。

@Composable
@Suppress("ComposableLambdaParameterNaming", "ComposableLambdaParameterPosition")
private fun BoxScope.SwitchImpl(
    checked: Boolean,
    enabled: Boolean,
    colors: SwitchColors,
    thumbValue: State<Float>,
    thumbContent: (@Composable () -> Unit)?,
    interactionSource: InteractionSource,
    thumbShape: Shape,
    uncheckedThumbDiameter: Dp,
    minBound: Dp,
    maxBound: Dp,
) {
    val trackColor by colors.trackColor(enabled, checked)
    val isPressed by interactionSource.collectIsPressedAsState()

    val thumbValueDp = with(LocalDensity.current) { thumbValue.value.toDp() }
    val thumbSizeDp = if (isPressed) {
        SwitchTokens.PressedHandleWidth
    } else {
        uncheckedThumbDiameter + (ThumbDiameter - uncheckedThumbDiameter) *
            ((thumbValueDp - minBound) / (maxBound - minBound))
    }

    val thumbOffset = if (isPressed) {
        with(LocalDensity.current) {
            if (checked) {
                ThumbPathLength - SwitchTokens.TrackOutlineWidth
            } else {
                SwitchTokens.TrackOutlineWidth
            }.toPx()
        }
    } else {
        thumbValue.value
    }

    val trackShape = SwitchTokens.TrackShape.toShape()
    val modifier = Modifier
        .align(Alignment.Center)
        .width(SwitchWidth)
        .height(SwitchHeight)
        .border(
            SwitchTokens.TrackOutlineWidth,
            colors.borderColor(enabled, checked).value,
            trackShape
        )
        .background(trackColor, trackShape)

    Box(modifier) {
        val thumbColor by colors.thumbColor(enabled, checked)
        val resolvedThumbColor = thumbColor
        Box(
            modifier = Modifier
                .align(Alignment.CenterStart)
                .offset { IntOffset(thumbOffset.roundToInt(), 0) }
                .indication(
                    interactionSource = interactionSource,
                    indication = rememberRipple(
                        bounded = false, SwitchTokens.StateLayerSize / 2)
                )
                .requiredSize(thumbSizeDp)
                .background(resolvedThumbColor, thumbShape),
            contentAlignment = Alignment.Center
        ) {
            if (thumbContent != null) {
                val iconColor = colors.iconColor(enabled, checked)
                CompositionLocalProvider(
                    LocalContentColor provides iconColor.value,
                    content = thumbContent
                )
            }
        }
    }
}

ここで行っていることは Thumb の大きさ thumbSizeDp と、 Thumb の位置 thumbOffset を Switch が押されているか、on か off か等によって計算しています。

そして Track の形や色を Modifier.width, height, border, background を用いて指定し modifier 変数に保存しています。

Track の形 trackShape = SwitchTokens.TrackShape.toShape() はソースコードを追っていくと以下の様になり左右の境界が半円になるような形を指定しています。

RoundedCornerShape(percent = 50)

最後に 2つの Box を入れ子にし、外側の Box で Track を描画し、内側の Box で Thumb を描画しています。 Thumb の位置は Modifer.offset で指定し、大きさを Modifier.requiredSize で指定、描画を Modifier.background で行っています。

全体としてまとめると、 on/off の状態や押された時の形状や色の変更、アニメーション動作のために複雑なことをしていますが、それらを除けば行っていることは以下の様になります。

  • Switch 関数と SwitchImpl 関数で Box を 3重の入れ子にし、一番外側の Box でプレス動作を検出し、真ん中の Box で Switch の Track 部分を描画し、最も内側の Box で Thumb 部分を描画しています。
  • Track の大きさは 2番目の Box で Modifier.width と Modifier.height を使って指定しています。
  • Track の描画は 2番目の Box の Modifier.border, Modifier.background で行います。
  • Thumb の位置は最も内側の Box の Modifier.offset で指定しています。
  • Thumb の大きさは最も内側の Box の Modifier.requiredSize で指定しています。
  • Thumb の描画は最も内側の Box の Modifier.bacdkground で行います。

この様に Button よりやや複雑な Switch は Box の 3重入れ子で構成され、ユーザーとのインターラクションや各部分の描画をそれぞれの Box に委ねることで実現していることが分かります。

Slider

以下の様な Slider を考えてみます。

@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
fun SliderPreview() {
    Slider(
        value = 50f,
        onValueChange = { },
        valueRange = 0f..100f,
        steps = 9
    )
}

slider.png

Slider も Switch 同様 Thumb と Track という 2つの部分で構成されています。 Thumb はつまみで Track 上でスライドして動作します。

Slider のソースコードを見てみましょう。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Slider(
    value: Float,
    onValueChange: (Float) -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
    /*@IntRange(from = 0)*/
    steps: Int = 0,
    onValueChangeFinished: (() -> Unit)? = null,
    colors: SliderColors = SliderDefaults.colors(),
    interactionSource: MutableInteractionSource =
        remember { MutableInteractionSource() }
) {
    Slider(
        value = value,
        onValueChange = onValueChange,
        modifier = modifier,
        enabled = enabled,
        valueRange = valueRange,
        steps = steps,
        onValueChangeFinished = onValueChangeFinished,
        colors = colors,
        interactionSource = interactionSource,
        thumb = remember(interactionSource, colors, enabled) { {
            SliderDefaults.Thumb(
                interactionSource = interactionSource,
                colors = colors,
                enabled = enabled
            )
        } },
        track = remember(colors, enabled) { { sliderPositions ->
            SliderDefaults.Track(
                colors = colors,
                enabled = enabled,
                sliderPositions = sliderPositions
            )
        } }
    )
}

Slider はデフォルトの SliderDefaults.Thumb 、 SliderDefaults.Track 以外にもユーザーが独自の Thumb, Track を作成することができます。ここではデフォルトのものを見ていきます。

まずは SliderDefaults.Thumb を見てみましょう。

@Composable
@ExperimentalMaterial3Api
fun Thumb(
    interactionSource: MutableInteractionSource,
    modifier: Modifier = Modifier,
    colors: SliderColors = colors(),
    enabled: Boolean = true,
    thumbSize: DpSize = ThumbSize
) {
    val interactions = remember { mutableStateListOf<Interaction>() }
    LaunchedEffect(interactionSource) {
        interactionSource.interactions.collect { interaction ->
            when (interaction) {
                is PressInteraction.Press -> interactions.add(interaction)
                is PressInteraction.Release -> interactions.remove(interaction.press)
                is PressInteraction.Cancel -> interactions.remove(interaction.press)
                is DragInteraction.Start -> interactions.add(interaction)
                is DragInteraction.Stop -> interactions.remove(interaction.start)
                is DragInteraction.Cancel -> interactions.remove(interaction.start)
            }
        }
    }

    val elevation = if (interactions.isNotEmpty()) {
        ThumbPressedElevation
    } else {
        ThumbDefaultElevation
    }
    val shape = SliderTokens.HandleShape.toShape()

    Spacer(
        modifier
            .size(thumbSize)
            .indication(
                interactionSource = interactionSource,
                indication = rememberRipple(
                    bounded = false,
                    radius = SliderTokens.StateLayerSize / 2
                )
            )
            .hoverable(interactionSource = interactionSource)
            .shadow(if (enabled) elevation else 0.dp, shape, clip = false)
            .background(colors.thumbColor(enabled).value, shape)
    )
}

ソースを見ると分かるように Thumb の実態は Spacer です。

まず引数として渡された interactionSource から PressInteraction や DragInteraction を取り出し、押している間やドラッグしている間はエレベーションを大きくしています。

また Thumb の形は shape に保存します。これはソースコードを追っていくと

val CircleShape = RoundedCornerShape(50)

となり、左右の境界が半円になるような形を指定しています。ここでは幅と高さが同じになるように指定されるので、円になります。

そして Spacer の Modifier によって Thumb を描画します。具体的には以下の様になります。

  • Modifier.size で Thumb の大きさを指定します。
  • Modifier.background で Thumb の色や形を描画します。
  • Modifier.shadow で Thumb を押している時やドラッグしている時に影を付けます。

ちなみに Spacer は以下の様に実装されています。これを見ると Spacer は中身の無い Layout であることが分かります。

@Composable
@NonRestartableComposable
fun Spacer(modifier: Modifier) {
    Layout({}, measurePolicy = SpacerMeasurePolicy, modifier = modifier)
}

次にデフォルトの Track のソースコードを見てみましょう。

@Composable
@ExperimentalMaterial3Api
fun Track(
    sliderPositions: SliderPositions,
    modifier: Modifier = Modifier,
    colors: SliderColors = colors(),
    enabled: Boolean = true,
) {
    val inactiveTrackColor = colors.trackColor(enabled, active = false)
    val activeTrackColor = colors.trackColor(enabled, active = true)
    val inactiveTickColor = colors.tickColor(enabled, active = false)
    val activeTickColor = colors.tickColor(enabled, active = true)
    Canvas(modifier
        .fillMaxWidth()
        .height(TrackHeight)
    ) {
        val isRtl = layoutDirection == LayoutDirection.Rtl
        val sliderLeft = Offset(0f, center.y)
        val sliderRight = Offset(size.width, center.y)
        val sliderStart = if (isRtl) sliderRight else sliderLeft
        val sliderEnd = if (isRtl) sliderLeft else sliderRight
        val tickSize = TickSize.toPx()
        val trackStrokeWidth = TrackHeight.toPx()
        drawLine(
            inactiveTrackColor.value,
            sliderStart,
            sliderEnd,
            trackStrokeWidth,
            StrokeCap.Round
        )
        val sliderValueEnd = Offset(
            sliderStart.x +
                (sliderEnd.x - sliderStart.x) * sliderPositions.positionFraction,
            center.y
        )

        val sliderValueStart = Offset(
            sliderStart.x +
                (sliderEnd.x - sliderStart.x) * 0f,
            center.y
        )

        drawLine(
            activeTrackColor.value,
            sliderValueStart,
            sliderValueEnd,
            trackStrokeWidth,
            StrokeCap.Round
        )
        sliderPositions.tickFractions.groupBy {
            it > sliderPositions.positionFraction ||
                it < 0f
        }.forEach { (outsideFraction, list) ->
                drawPoints(
                    list.map {
                        Offset(lerp(sliderStart, sliderEnd, it).x, center.y)
                    },
                    PointMode.Points,
                    (if (outsideFraction) inactiveTickColor
                     else activeTickColor).value,
                    tickSize,
                    StrokeCap.Round
                )
            }
    }
}

これを見ると Track の実態は Canvas となっています。
Canvas の特徴は onDraw 引数が DrawScope を Receiver とする関数となっていることです。これにより onDraw 関数では this が DrawScope を指すことになり size プロパティを用いて描画領域の大きさが取得できます。また drawLinedrawCircle 等さまざまな描画関数を使用することで複雑な図形を描画することができます。さらに drawText を使えば文字列を Canvas に描画することもできます。

ちなみに Canvas は以下の様に実装されており、実態は Spacer であることが分かります。 Spacer の Modifier.drawBehind に描画関数を渡しています。

@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
    Spacer(modifier.drawBehind(onDraw))

ここではまず Track 全体を inactiveTrackColor 引数で渡された色で線を引くことで描画します(最初の drawLine)。 そして左端(LTR, Left To Right の場合)から sliderPositions 引数で受け取った Thumb の位置まで activeTrackColor 引数で渡された色で線を上書きします(2番目の drawLine)。これによって最初の図のように Thumb の左右で色が異なる様な線を描画します。
その後 Tick(Track の中の点) を drawPoints を使って描画します。

さらに Slider 関数の中身を追ってみます。 Slider 関数はそのまま引数を SliderImpl 関数に渡しています。

@Composable
@ExperimentalMaterial3Api
fun Slider(
    value: Float,
    onValueChange: (Float) -> Unit,
    track: @Composable (SliderPositions) -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
    /*@IntRange(from = 0)*/
    steps: Int = 0,
    onValueChangeFinished: (() -> Unit)? = null,
    colors: SliderColors = SliderDefaults.colors(),
    interactionSource: MutableInteractionSource =
        remember { MutableInteractionSource() },
    thumb: @Composable (SliderPositions) -> Unit =
        remember(interactionSource, colors, enabled) { {
            SliderDefaults.Thumb(
                interactionSource = interactionSource,
                colors = colors,
                enabled = enabled
            )
        } }
) {
    require(steps >= 0) { "steps should be >= 0" }

    SliderImpl(
        modifier = modifier,
        enabled = enabled,
        interactionSource = interactionSource,
        onValueChange = onValueChange,
        onValueChangeFinished = onValueChangeFinished,
        steps = steps,
        value = value,
        valueRange = valueRange,
        thumb = thumb,
        track = track
    )
}

そして SliderImpl 関数は以下の様になっています。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SliderImpl(
    modifier: Modifier,
    enabled: Boolean,
    interactionSource: MutableInteractionSource,
    onValueChange: (Float) -> Unit,
    onValueChangeFinished: (() -> Unit)?,
    steps: Int,
    value: Float,
    valueRange: ClosedFloatingPointRange<Float>,
    thumb: @Composable (SliderPositions) -> Unit,
    track: @Composable (SliderPositions) -> Unit
) {
    val onValueChangeState = rememberUpdatedState<(Float) -> Unit> {
        if (it != value) {
            onValueChange(it)
        }
    }

    val tickFractions = remember(steps) {
        stepsToTickFractions(steps)
    }

    val thumbWidth = remember { mutableStateOf(ThumbWidth.value) }
    val totalWidth = remember { mutableStateOf(0) }

    fun scaleToUserValue(minPx: Float, maxPx: Float, offset: Float) =
        scale(minPx, maxPx, offset, valueRange.start, valueRange.endInclusive)

    fun scaleToOffset(minPx: Float, maxPx: Float, userValue: Float) =
        scale(valueRange.start, valueRange.endInclusive, userValue, minPx, maxPx)

    val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
    val rawOffset = remember { mutableStateOf(scaleToOffset(0f, 0f, value)) }
    val pressOffset = remember { mutableStateOf(0f) }
    val coerced = value.coerceIn(valueRange.start, valueRange.endInclusive)

    val positionFraction =
        calcFraction(valueRange.start, valueRange.endInclusive, coerced)
    val sliderPositions =
        remember { SliderPositions(positionFraction, tickFractions) }
    sliderPositions.positionFraction = positionFraction
    sliderPositions.tickFractions = tickFractions

    val draggableState = remember(valueRange) {
        SliderDraggableState {
            val maxPx = max(totalWidth.value - thumbWidth.value / 2, 0f)
            val minPx = min(thumbWidth.value / 2, maxPx)
            rawOffset.value = (rawOffset.value + it + pressOffset.value)
            pressOffset.value = 0f
            val offsetInTrack =
                snapValueToTick(rawOffset.value, tickFractions, minPx, maxPx)
            onValueChangeState.value.invoke(
                scaleToUserValue(minPx, maxPx, offsetInTrack))
        }
    }

    val gestureEndAction = rememberUpdatedState {
        if (!draggableState.isDragging) {
            // check isDragging in case the change is
            // still in progress (touch -> drag case)
            onValueChangeFinished?.invoke()
        }
    }

    val press = Modifier.sliderTapModifier(
        draggableState,
        interactionSource,
        totalWidth.value,
        isRtl,
        rawOffset,
        gestureEndAction,
        pressOffset,
        enabled
    )

    val drag = Modifier.draggable(
        orientation = Orientation.Horizontal,
        reverseDirection = isRtl,
        enabled = enabled,
        interactionSource = interactionSource,
        onDragStopped = { _ -> gestureEndAction.value.invoke() },
        startDragImmediately = draggableState.isDragging,
        state = draggableState
    )

    Layout(
        {
            Box(modifier = Modifier.layoutId(SliderComponents.THUMB)) {
                thumb(sliderPositions) }
            Box(modifier = Modifier.layoutId(SliderComponents.TRACK)) {
                track(sliderPositions) }
        },
        modifier = modifier
            .minimumTouchTargetSize()
            .requiredSizeIn(
                minWidth = SliderTokens.HandleWidth,
                minHeight = SliderTokens.HandleHeight
            )
            .sliderSemantics(
                value,
                enabled,
                onValueChange,
                onValueChangeFinished,
                valueRange,
                steps
            )
            .focusable(enabled, interactionSource)
            .then(press)
            .then(drag)
    ) { measurables, constraints ->

        val thumbPlaceable = measurables.first {
            it.layoutId == SliderComponents.THUMB
        }.measure(constraints)

        val maxTrackWidth = constraints.maxWidth - thumbPlaceable.width
        val trackPlaceable = measurables.first {
            it.layoutId == SliderComponents.TRACK
        }.measure(
            constraints.copy(
                minWidth = 0,
                maxWidth = maxTrackWidth,
                minHeight = 0
            )
        )

        val sliderWidth = thumbPlaceable.width + trackPlaceable.width
        val sliderHeight = max(trackPlaceable.height, thumbPlaceable.height)

        thumbWidth.value = thumbPlaceable.width.toFloat()
        totalWidth.value = sliderWidth

        val trackOffsetX = thumbPlaceable.width / 2
        val thumbOffsetX = ((trackPlaceable.width) * positionFraction).roundToInt()
        val trackOffsetY = (sliderHeight - trackPlaceable.height) / 2
        val thumbOffsetY = (sliderHeight - thumbPlaceable.height) / 2

        layout(
            sliderWidth,
            sliderHeight
        ) {
            trackPlaceable.placeRelative(
                trackOffsetX,
                trackOffsetY
            )
            thumbPlaceable.placeRelative(
                thumbOffsetX,
                thumbOffsetY
            )
        }
    }
}

ここで行っているのは大まかには以下のとおりです。

  • Tick を描画する位置を計算し tickFractions に保存します。これは Track の長さを 1 とした時の相対的な位置です。
  • Thumb や Track の幅を保存するState 変数 thumbWidth , totalWidth を用意します。これは Layout 時に値が設定されます。
  • Thumb が Track のどこに位置するかを Tick による制限などを考慮しながら計算し positionFraction に保存します。これは Track の長さを 1 とした時の相対的な位置となります。
  • Slider が押された時に応答する機能を作成し press 変数に保存します。
  • Slider がドラッグされた時に応答する機能を作成し drag 変数に保存します。
  • Layout Composableを用いて Thumb と Track を適切な位置に配置します。その際両方とも Box に入れています。 Box は Modifier.layoutId を指定するために使用されていると思われます。 Layout Composable の説明は こちら となります。
  • Layout の Modifier に先程のプレスされたときやドラッグされた時に応答する機能 press , drag を渡します。

この様に Slider では Spacer と Canvas を用いて Thumb と Track を作成し、 Layout を用いてそれぞれを適切な場所に配置しています。

なお、ここに出てくる Modifier.sliderTapModifier(...) は Slider に固有の Modifier の拡張関数です。ここでは Modifier.pointerInput 拡張関数を使用して作成しています。pointerInput の簡単な使用方法は こちら に説明があります。

pointerInput 関数の block 引数は PointerInputScope を Receiver とする関数ですので、この関数内では detectTapGestures 等が使用できます。

private fun Modifier.sliderTapModifier(
    draggableState: DraggableState,
    interactionSource: MutableInteractionSource,
    maxPx: Int,
    isRtl: Boolean,
    rawOffset: State<Float>,
    gestureEndAction: State<() -> Unit>,
    pressOffset: MutableState<Float>,
    enabled: Boolean
) = composed(
    factory = {
        if (enabled) {
            val scope = rememberCoroutineScope()
            pointerInput(draggableState, interactionSource, maxPx, isRtl) {
                detectTapGestures(
                    onPress = { pos ->
                        val to = if (isRtl) maxPx - pos.x else pos.x
                        pressOffset.value = to - rawOffset.value
                        try {
                            awaitRelease()
                        } catch (_: GestureCancellationException) {
                            pressOffset.value = 0f
                        }
                    },
                    onTap = {
                        scope.launch {
                            draggableState.drag(MutatePriority.UserInput) {
                                // just trigger animation,
                                // press offset will be applied
                                dragBy(0f)
                            }
                            gestureEndAction.value.invoke()
                        }
                    }
                )
            }
        } else {
            this
        }
    },
    inspectorInfo = debugInspectorInfo {
        name = "sliderTapModifier"
        properties["draggableState"] = draggableState
        properties["interactionSource"] = interactionSource
        properties["maxPx"] = maxPx
        properties["isRtl"] = isRtl
        properties["rawOffset"] = rawOffset
        properties["gestureEndAction"] = gestureEndAction
        properties["pressOffset"] = pressOffset
        properties["enabled"] = enabled
    })

まとめ

Jetpack Compose の Component の作り方を調べ始めた時は、なんとなく View の onDraw 関数のようなもので線を引いたり、円を描いたりして Component を描画していると思っていました。

しかし実際には簡単な図形は Surface または Box, Spacer の Modifier を使って、予め定義されている Shape オブジェクトや Color オブジェクトを渡すことで描画していました。調べた範囲では Slot を持つ Component は Box(または Surface) を用い、Slot を持たない場合は Spacer を使用していました。

一方 Slider の Track の様にスライド部とその中に複数の Tick が存在するような比較的複雑な図形の場合には Canvas を用いて予想通り drawLine や drawPoints 等を用いて描画していました。

またユーザーがタップしたりドラッグしたりした時の応答機構の殆どは Modifier に予め用意されている Modifier.clickable 等を使って実現していました。独自の複雑な応答機構が必要な場合には Modifier.pointerInput を用いて実装していました。

12
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
10