はじめに
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 のソースコードを見てみましょう。
@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 をタップした時の応答機能は以下の様に onClick
と interactionSource を 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 = {})
}
@Preview
@Composable
fun SwitchPreview() {
Switch(checked = false, onCheckedChange = {})
}
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 も 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 プロパティを用いて描画領域の大きさが取得できます。また drawLine や drawCircle 等さまざまな描画関数を使用することで複雑な図形を描画することができます。さらに 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 を用いて実装していました。