Design Division 所属 Androidエンジニアのスージです。 良いデザインでクライアントのビジネスを前進させるために開発している、Jetpack Compose と KMM が好きな Android エンジニアです。
最近 Android ツイッターコミュニティで話題になった SwiftUI で実装された Parallax エフェクトを、早速 Jetpack Compose での再現に挑戦しました。一番苦労したのは端末のセンサーデータの取得なんですけど(笑)、結果的に短期間で結構良い感じに実現できました。
Tried it on #JetpackCompose !🥳 #AndroidDev
— suraj・🍜・🍙 (@_SUR4J_) 2022年5月26日
@JimSproch https://t.co/Ic1LMruPzD pic.twitter.com/vxTJDiF1Wm
今回はこの Parallax Effect の実装、そして実装中に Jetpack Compose で気をつけないといけないことについて、少し紹介しようと思ってます。
実装
今から説明するコードはこの gist で書いてあります。 Parallax effect with Jetpack Compose · GitHub
センサーデータ取得
スマホの傾きに応じてビューを調整するため、スマホの Pitch と Roll の取得が必要です。
Pitch と Roll 取得するために、この StackOverflow の回答を参考にしました。
var gravity: FloatArray? = null var geomagnetic: FloatArray? = null override fun onSensorChanged(event: SensorEvent?) { if (event?.sensor?.type == Sensor.TYPE_GRAVITY) gravity = event.values if (event?.sensor?.type == Sensor.TYPE_MAGNETIC_FIELD) geomagnetic = event.values if (gravity != null && geomagnetic != null) { var r = FloatArray(9) var i = FloatArray(9) if (SensorManager.getRotationMatrix(r, i, gravity, geomagnetic)) { var orientation = FloatArray(3) SensorManager.getOrientation(r, orientation) // pitch = orientation[1] // roll = orientation[2] } } }
SensorManager.registerListener
DisposableEffect
を使って SensorManager
の初期化と、データ取得のため TYPE_GRAVITY
と TYPE_MAGNETIC_FIELD
Sensor の SensorManager.registerListener()
を呼び出す。
そして、センサーデータをChannel.receiveAsFlow().collect()
で取得します。
// DisposableEffect を使って SensorManager の初期化 // @Composable fun ParallaxScreen() val context = LocalContext.current val scope = rememberCoroutineScope() var data by remember { mutableStateOf<SensorData?>(null) } DisposableEffect(Unit) { val dataManager = SensorDataManager(context) dataManager.init() scope.launch { dataManager.data .receiveAsFlow() .onEach { data = it } .collect() } }
// SensorManager の wrapper 実装 // class SensorDataManager fun init() { val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) val magnetometer = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD) sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_UI) sensorManager.registerListener(this, magnetometer, SensorManager.SENSOR_DELAY_UI) } val data: Channel<SensorData> = Channel(Channel.UNLIMITED) override fun onSensorChanged(event: SensorEvent?) { .. data.trySend( SensorData( roll = orientation[2], pitch = orientation[1] ) ) } data class SensorData( val roll: Float, val pitch: Float )
ここでは Flow.collect()
しかしていないので LaunchedEffect
を使ってもいいんじゃないかなと思われますが、理由は後で説明します(onDispose{}
はヒントです)。
取得したデータを使ってビューの調整
では、今取得したセンサーデータに応じてビューの調整について話しましょう。これに関して Philips 氏もツイートでいくつかのヒントを書いてくれました: - Image Card の動き - Glow shadow の動き(Card と逆方向) - 3D 感を出すため、スマホが傾くと Card のエッジが見えること - 中の写真の動き(Card と逆方向)
Image Card
Image( painter = painterResource(id = R.drawable.beach), modifier = Modifier // [1] .offset { // [2] IntOffset( x = roll.dp.roundToPx(), y = -pitch.dp.roundToPx() ) } .. .size(width = 300.dp, height = 400.dp), alignment = BiasAlignment( // [3] horizontalBias = (roll * 0.005).toFloat(), verticalBias = 0f, ) )
[1] Modifier.offset
を使って @Composable ビューの位置を調整します。
[2] roll
は y-axis と pitch
は x-axis に沿った傾きなので、roll
は x-offset と pitch
は y-offset を調整します。
[3] 中の写真の微妙な Parallax effect を演出するために @Composable Image()
の alignment パラメータの horizontalBias
を Image Card と逆方向に設定します。
Glow Shadow
Glow Shadow の実装では Philips 氏の実装を参考にしました。
Image( painter = painterResource(id = R.drawable.beach), modifier = Modifier .offset { // [1] IntOffset( x = -(roll * 1.5).dp.roundToPx(), y = (pitch * 2).dp.roundToPx() ) } // [2] .size(width = 256.dp, height = 356.dp) // [3] .blur(radius = 24.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded), )
[1] Image Card より早い速度と逆方向に位置を調整します。
[2] Image Card よりサイズを少し小さくします。
- スマホが平地に置いてあるとき(
roll
とpitch
は 0 になるとき)Glow Shadow が見えない - 影が薄くなってない部分は実物よりサイズが小さい
[3] 半透明である物の影を演出するため blur 入れます。
Card Edge
Box( modifier = Modifier .offset { // [1] IntOffset( x = (roll * 0.9).dp.roundToPx(), y = -(pitch * 0.9).dp.roundToPx() ) } // [2] .size(width = 300.dp, height = 400.dp) .background( color = Color.White.copy(alpha = 0.3f), shape = RoundedCornerShape(16.dp) ), )
[1] Image Card より若干遅い。
- スマホが平地に置いてあるときエッジが見えない
- スマホが傾いてるとき、Image Card から若干ずらして見せる
[2] Image Card と同じサイズにします。
注意点
DisposableEffect.onDispose
でセンサーデータ取得を止める
この @Composable
は Recomposition から外されたとき SensorManager のメモリーリークを回避するために SensorManager.unregisterListener()
を呼び出すべきです。
// in @Composable fun ParallaxScreen() DisposableEffect(Unit) { // SensorManager initiated & Sensor.registerListener() called // here val dataManager = .. dataManager.init() val job = dataManager.data .. .collect() onDispose { dataManager.cancel() job.cancel() } }
// in class SensorDataManager fun cancel() { sensorManager.unregisterListener(this) }
正しい Modifier.offset
を使いましょう
Compose には offset を定義するため2つの Modifier メソッドあります:
.offset(x: Dp = 0.dp, y: Dp = 0.dp)
.offset(offset: Density.() -> IntOffset)
この2つがやってることは同じですが、更新される offset(例えば、アニメーションで offset の更新) を定義する場合 .offset(offset: Density.() -> IntOffset)
を使うべきです!なぜかというと、後者は offset が更新されるときに Recomposition を実行しないからですね。ドキュメンテーションにも以下のように書かれています。
This modifier is designed to be used for offsets that change, possibly due to user interactions. It avoids recomposition when the offset is changing, and also adds a graphics layer that prevents unnecessary redrawing of the context when the offset is changing.
詳しくはこちら。 developer.android.com
終わりに
もちろん、今回紹介した実装は最適化されたものではありません。短期間で品質の高いエフェクトをより簡単に実現できる事例を、最近ツイッターでよく見ますよね。 SwiftUI と Jetpack Compose を使って開発者体験そしてユーザー体験が向上することを信じてます!
Goodpatch には、デザインと技術の両方を追求できる環境があります。 少しでもご興味を持ってくださった方、ぜひ一度カジュアルにお話ししましょう!