センサーデータを使って Jetpack Compose で Parallax エフェクトを作ってみた

Design Division 所属 Androidエンジニアのスージです。 良いデザインでクライアントのビジネスを前進させるために開発している、Jetpack Compose と KMM が好きな Android エンジニアです。

最近 Android ツイッターコミュニティで話題になった SwiftUI で実装された Parallax エフェクトを、早速 Jetpack Compose での再現に挑戦しました。一番苦労したのは端末のセンサーデータの取得なんですけど(笑)、結果的に短期間で結構良い感じに実現できました。

今回はこの Parallax Effect の実装、そして実装中に Jetpack Compose で気をつけないといけないことについて、少し紹介しようと思ってます。

実装

今から説明するコードはこの gist で書いてあります。 Parallax effect with Jetpack Compose · GitHub

センサーデータ取得

スマホの傾きに応じてビューを調整するため、スマホの Pitch と Roll の取得が必要です。

Pitch と Roll 取得するために、この StackOverflow の回答を参考にしました。

stackoverflow.com

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_GRAVITYTYPE_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 と逆方向に設定します。

デバイス傾きに沿って Image Card の調整

Glow Shadow

Glow Shadow の実装では Philips 氏の実装を参考にしました。

github.com

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 よりサイズを少し小さくします。

  • スマホが平地に置いてあるとき(rollpitch は 0 になるとき)Glow Shadow が見えない
  • 影が薄くなってない部分は実物よりサイズが小さい

[3] 半透明である物の影を演出するため blur 入れます。

Image Card に沿って Glow Shadow の調整

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 と同じサイズにします。

Image Card に沿って Card Edge の調整

注意点

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 には、デザインと技術の両方を追求できる環境があります。 少しでもご興味を持ってくださった方、ぜひ一度カジュアルにお話ししましょう!