inSmartBank

B/43を運営する株式会社スマートバンクのメンバーによるブログです

SwiftUI / Jetpack Composeでよくある画像のプレビュー画面を実装する

こんにちは。スマートバンクで iOS / Android エンジニアをしている nakamuuu です。

先日リリースした新しいメンバーシッププラン「B/43プラス」では支払いの明細へ画像を添付できるようになりました!添付した画像は明細画面にサムネイル状に表示され、タップするとプレビュー画面へと遷移して画像が大きく表示されます。

今回、私はこのような画像のプレビュー機能を iOS / Android の両OSそれぞれで SwiftUI / Jetpack Compose を用いて実装しました。

このエントリーでは SwiftUI / Jetpack Compose の各フレームワークにおけるジェスチャー操作のAPIや振る舞いの差異を踏まえつつ、 “よくある画像のプレビュー画面” を実装するにあたっての知見を紹介していこうと思います。

プレビュー画面の要件

上述したプレビュー画面ではユーザーのジェスチャー操作に応じて以下の振る舞いをします。それぞれSNSアプリなどのプレビュー画面でもよくある仕様でしょう。

📝 プレビュー画面のジェスチャー操作

  • ① ピンチインで画像を拡大表示する
  • ② 画像が拡大された状態でのドラッグで表示位置を移動する
  • ③ 水平方向のスワイプで前後のページへ移動する
  • ④ 垂直方向のスワイプで画面を閉じる

① ピンチインで画像を拡大表示する / ② 画像が拡大された状態でのドラッグで表示位置を移動する

③ 水平方向のスワイプで前後のページへ移動する / ④ 垂直方向のスワイプで画面を閉じる

SwiftUI / Jetpack Compose の各フレームワークにはユーザーのジェスチャー操作を検出する多様な Modifier があらかじめ用意されています。それぞれ個々の振る舞いを実装することはさほど難しいことではないと思います。

developer.apple.com

developer.android.com

しかし、安直に組み込むと ② の表示位置のドラッグと ③ ④ のスワイプ操作が同時に発火するなど、意図しない動作を招きかねません。例えば以下の動画のように、画像が拡大された状態で左右にドラッグした場合、画像端に到達してから前後のページへの移動を行う必要があります*1

画像が拡大された状態で左右にドラッグした場合、画像端に到達してから前後のページへの移動を行う必要がある

このように1画面に複数のジェスチャー操作を組み合わせる場合には “どのジェスチャーがタッチイベントを処理すべきか” を意識した実装が求められます。

各フレームワークでジェスチャー操作を組み合わせる

複数のジェスチャーを組み合わせる方法を各フレームワークごとに簡単に説明していきます。

SwiftUI

ジェスチャー操作に対して優先度を指定する方法として、SwiftUIでは以下のような Modifier やGesture の関数が用意されています。

developer.apple.com

  • Modifier
    • simultaneousGesture(Gesture) : 同一のViewの他のジェスチャーあるいは子Viewに付与されたジェスチャーと並列で処理したいジェスチャーを指定するModifier
    • highPriorityGesture(Gesture) : 同一のViewの他のジェスチャーあるいは子Viewに付与されたジェスチャーより優先して処理したいジェスチャーを指定するModifier
  • Gesture
    • simultaneously(with: Gesture) : 自身と並列で処理したいジェスチャーを指定する
    • sequenced(before: Gesture) : 自身のジェスチャーの検出後に処理したいジェスチャーを指定する
    • exclusively(before: Gesture) : 自身と排他的に処理したいジェスチャーを指定する
// 2つの `DragGesture` がどちらも処理される
Rectangle()
    .gesture(
        DragGesture()
            .onChanged { _ in print("drag") }
    )
    .simultaneousGesture(
        DragGesture()
            .onChanged { _ in print("drag 2") }
    )

// `ZStack` に付与した `DragGesture` のみが処理される
ZStack {
    Rectangle()
        .gesture(
            DragGesture()
                .onChanged { _ in print("drag") }
        )
}
.highPriorityGesture(
    DragGesture()
        .onChanged { _ in print("drag 2") }
)
// 2つの `DragGesture` がどちらも処理される
Rectangle()
    .gesture(
        DragGesture()
            .onChanged { _ in print("drag") }
            .simultaneously(
                with: DragGesture()
                    .onChanged { _ in print("drag 2") }
            )
    )

// 1.0秒間の長押しの検出後に `DragGesture` が処理される
Rectangle()
    .gesture(
        LongPressGesture(minimumDuration: 1.0)
            .sequenced(
                before: DragGesture()
                    .onChanged { _ in print("long press then drag") }
            )
    )

// 2回タップと3回タップのいずれかが排他的に処理される
Rectangle()
    .gesture(
        TapGesture(count: 2)
            .onEnded { print("two tap") }
            .exclusively(
                before: TapGesture(count: 3)
                    .onEnded { print("triple tap") }
            )
    )

これらの Modifier や Gesture の機能を用いると、比較的少数のジェスチャーを組み合わせる場合はかなり簡潔に実装を進められるでしょう。一方でジェスチャーの優先順が動的に変わる…前述した “画像端に到達してから前後のページへの移動を行う” のような仕様を満たそうとすると、Gesture の単純な組み合わせでの実装は難しくなります。

Jetpack Compose

“どのジェスチャーがタッチイベントを処理すべきか” を意識する中で、Jetpack Compose はより柔軟なアプローチを用意している印象です。旧来のViewベースのレイアウトシステムと同様に “イベントの消費” の概念が組み込まれた低レベルのAPIを利用できます。

ジェスチャーの優先順を管理する方法は SwiftUI のような Modifier としては用意されていません。複数の競合する Modifier を並べても、一方のジェスチャーのみが処理されます。

// 後者のドラッグのみが処理される
Spacer(
    modifier = Modifier.draggable(...).draggable(...)
)

Jetpack Compose ではジェスチャー操作を低レベルで処理するための pointerInput Modifier が用意されています*2。その pointerInput のコールバックの中では特定のタッチイベントの変動を表す PointerInputChange を扱うことになりますが、このクラスが消費状態を表すプロパティ( isConsumed )を持っています。

例としてすべてのタッチイベントを消費する実装を以下に示します。ここへタッチ座標などの適切なハンドリングと consume() の条件に応じた呼び出しを加えていくことで、ジェスチャーの優先順が動的に変わるような実装を行えます。

// すべてのタッチイベントが子Viewの `pointerInput` 内で消費されるので `clickable` は機能しない
Box(
    modifier = Modifier.fillMaxSize()
        .clickable { println("clicked") }
) {
    Spacer(
        modifier = Modifier.fillMaxSize()
            .pointerInput(Unit) {
                // `awaitEachGesture` : ジェスチャーの開始まで待機してコールバックを呼び出すsuspend関数
                awaitEachGesture {
                    // 画面からすべての指が離れるまで `PointerInputChange` を消費し続ける
                    do {
                        // `awaitPointerEvent` : タッチイベントの変動を待機して `PointerInputChange` を返すsusupend関数
                        val event = awaitPointerEvent()
                        event.changes.forEach { it.consume() }
                    } while (event.changes.any { it.pressed })
                }
            }
    )
}

プレビュー画面の実装方法

ここまで触れてきた Swift / Jetpack Compose の各フレームワークの仕様により、プレビュー画面の実装方法にどのような差が生まれるでしょうか。

エントリーの冒頭ではプレビュー画面の4つのジェスチャー操作について説明しました。

📝 プレビュー画面のジェスチャー操作

  • ① ピンチインで画像を拡大表示する
  • ② 画像が拡大された状態でのドラッグで表示位置を移動する
  • ③ 水平方向のスワイプで前後のページへ移動する
  • ④ 垂直方向のスワイプで画面を閉じる

このうち「③ 水平方向のスワイプで前後のページへ移動する」「④ 垂直方向のスワイプで画面を閉じる」の実装が “イベントの消費” の概念が組み込まれたAPIの有無によって大きく変わってきます。

SwiftUI

愚直に思いつくのは ScrollView 内に水平方向に画像を並べる実装ですが、SwiftUIにおいてこの実装方法は難しいでしょう。子Viewとなる各画像に対して ① ② のジェスチャー操作を実装すると、ドラッグのジェスチャーが親まで伝播しないためです。

ScrollView(.horizontal, showsIndicators: false) {
    HStack(spacing: 0) {
        ForEach(0..<imageNames.count) { index in
            Image(self.imageNames[index])
                .resizable()
                .aspectRatio(contentMode: .fit)
                .containerRelativeFrame(.horizontal)
                .containerRelativeFrame(.vertical)
                .gesture(
                    // この `DragGesture` が常に優先して処理されるので、前後のページへのスクロールが機能しない
                    DragGesture(coordinateSpace: .global)
                        .onChanged { ... }
                        .onEnded { ... }
                )
                .simultaneousGesture(
                    // `MagnificationGesture` : ピンチインによる拡大・縮小用の Gesture
                    MagnificationGesture()
                        .onChanged { ... }
                        .onEnded { ... }
                )
        }
        .scrollTargetLayout()
    }
    .scrollTargetBehavior(.paging)
}

※ SwiftUI には iOS 16 時点で HorizontalPager (Jetpack Compose) 相当の実装が用意されていないため、この点でも ScrollView を使用することができません。話が本筋から離れてしまうため、ここでは iOS 17 で新設される scrollTargetBehavior / containerRelativeFrame Modifierを用いて例を示します。

この問題を回避するために、B/43のプレビュー画面では各画像に設定する以下のようなカスタム Modifier を用意しています。なかなかに長大な実装ですが、ポイントとなるのは画像端を超えてドラッグした際の移動量をコールバック( onDraggingOver )で受け取れるようにしていることです。

private struct ImageGestureModifier: ViewModifier {
    let pageSize: CGSize
    let imageSize: CGSize

    // ✅ 画像端を超えてドラッグした際の移動量をコールバックで受け取れるようにしている
    let onDraggingOver: (CGSize) -> Void
    let onDraggingOverEnded: (CGSize) -> Void
    let onDraggingOverCanceled: () -> Void

    @State private var currentScale: CGFloat = 1.0
    @State private var previousScale: CGFloat = 1.0

    @State private var currentOffset = CGSize.zero
    @State private var unclampedOffset = CGSize.zero
    @State private var previousTranslation = CGSize.zero

    @State private var draggingOverAxis: DraggingOverAxis?

    // ドラッグ操作用の Gesture
    var dragGesture: some Gesture {
        DragGesture(coordinateSpace: .global)
            .onChanged { value in
                handleDragGestureValueChanged(value)
            }
            .onEnded { value in
                handleDragGestureValueChanged(value)

                previousTranslation = .zero
                unclampedOffset = currentOffset

                let (draggableRangeX, draggableRangeY) = calculateDraggableRange()
                if draggingOverAxis == .horizontal {
                    if currentOffset.width <= draggableRangeX.lowerBound || draggableRangeX.upperBound <= currentOffset.width {
                        onDraggingOverEnded(CGSize(width: value.predictedEndTranslation.width, height: 0))
                    } else {
                        onDraggingOverCanceled()
                    }
                } else if draggingOverAxis == .vertical {
                    if currentOffset.height <= draggableRangeY.lowerBound || draggableRangeY.upperBound <= currentOffset.height {
                        onDraggingOverEnded(CGSize(width: 0, height: value.predictedEndTranslation.height))
                    } else {
                        onDraggingOverCanceled()
                    }
                }

                draggingOverAxis = nil
            }
    }
    // ピンチインでの拡大・縮小操作用の Gesture
    var pinchGesture: some Gesture {
        MagnificationGesture()
            .onChanged { value in
                let delta = value / previousScale
                previousScale = value
                currentScale = clamp(currentScale * delta, 1.0, 2.5)
            }
            .onEnded { _ in
                previousScale = 1.0
                withAnimation {
                    currentOffset = clampInDraggableRange(offset: currentOffset)
                }
            }
    }

    func body(content: Content) -> some View {
        content.offset(x: currentOffset.width, y: currentOffset.height)
            .scaleEffect(currentScale)
            .clipShape(Rectangle())
            .gesture(dragGesture)
            .simultaneousGesture(pinchGesture)
    }
    
    /// ドラッグ操作の移動量から画像の表示位置(オフセット)を確定させる
    ///
    /// 画像端を超えてドラッグしていた場合は移動量を `onDraggingOver` のコールバックに通知する。
    private func handleDragGestureValueChanged(_ value: DragGesture.Value) {
        let delta = CGSize(
            width: value.translation.width - previousTranslation.width,
            height: value.translation.height - previousTranslation.height
        )
        previousTranslation = CGSize(
            width: value.translation.width,
            height: value.translation.height
        )
        unclampedOffset = CGSize(
            width: unclampedOffset.width + delta.width / currentScale,
            height: unclampedOffset.height + delta.height / currentScale
        )
        currentOffset = clampInDraggableRange(offset: unclampedOffset)
        
        // ✅ 画像端を考慮したオフセット( `currentOffset` )と考慮しないオフセット( `unclampedOffset` )に差がある場合にコールバックを呼び出す
        // 画像端を超えてドラッグを開始した後はもう一方向の移動量を無視し、前後の画像への切り替えと画面を閉じる操作を同時に機能させない
        switch draggingOverAxis {
        case .horizontal:
            if unclampedOffset.width != currentOffset.width {
                onDraggingOver(CGSize(width: unclampedOffset.width - currentOffset.width, height: 0))
            } else {
                draggingOverAxis = nil
                onDraggingOverCanceled()
            }
        case .vertical:
            if unclampedOffset.height != currentOffset.height {
                onDraggingOver(CGSize(width: 0, height: unclampedOffset.height - currentOffset.height))
            } else {
                draggingOverAxis = nil
                onDraggingOverCanceled()
            }
        case nil:
            if unclampedOffset != currentOffset {
                if abs(unclampedOffset.width - currentOffset.width) > abs(unclampedOffset.height - currentOffset.height) {
                    draggingOverAxis = .horizontal
                    onDraggingOver(CGSize(width: unclampedOffset.width - currentOffset.width, height: 0))
                } else {
                    draggingOverAxis = .vertical
                    onDraggingOver(CGSize(width: 0, height: unclampedOffset.height - currentOffset.height))
                }
            }
        }
    }

    private func calculateDraggableRange() -> (ClosedRange<CGFloat>, ClosedRange<CGFloat>) {
        let scaledImageSize = CGSize(
            width: imageSize.width * currentScale,
            height: imageSize.height * currentScale
        )
        let draggableSize = CGSize(
            width: max(0, scaledImageSize.width - pageSize.width),
            height: max(0, scaledImageSize.height - pageSize.height)
        )
        return (
            -(draggableSize.width / 2 / currentScale)...(draggableSize.width / 2 / currentScale),
            -(draggableSize.height / 2 / currentScale)...(draggableSize.height / 2 / currentScale)
        )
    }

    private func clampInDraggableRange(offset: CGSize) -> CGSize {
        let (draggableHorizontalRange, draggableVerticalRange) = calculateDraggableRange()
        return CGSize(
            width: clamp(
                offset.width,
                draggableHorizontalRange.lowerBound,
                draggableHorizontalRange.upperBound
            ),
            height: clamp(
                offset.height,
                draggableVerticalRange.lowerBound,
                draggableVerticalRange.upperBound
            )
        )
    }

    private enum DraggingOverAxis: Equatable {
        case horizontal
        case vertical
    }
}

この Modifier から得られるドラッグの移動量を基に親Viewのオフセットを変える(=擬似的に HorizontalPager の振る舞いを再現する)ことで以下の2つの振る舞いを実現します。

  • ③ 水平方向のスワイプで前後のページへ移動する
  • ④ 垂直方向のスワイプで画面を閉じる
struct ImagePager: View {
    @State private var pagerState: ImagePagerState
    let imageUrls: [URL]
    let onDismiss: () -> Void

    var body: some View {
        GeometryReader { geometry in
            let pageSize = geometry.size
            HStack(spacing: 0) {
                ForEach(imageUrls, id: \.absoluteString) { imageUrl in
                    ImagePagerPage(
                        pagerState: $pagerState,
                        imageUrl: imageUrl,
                        pageSize: pageSize,
                        onDismiss: onDismiss
                    ).frame(width: pageSize.width, height: pageSize.height)
                }
            }
            .frame(width: pageSize.width * CGFloat(pagerState.pageCount), height: pageSize.height)
            // ✅ オフセットを変えることで擬似的に HorizontalPager の振る舞いを再現する
            .offset(pagerState.offset)
        }
    }
}

private struct ImagePagerPage: View {
    @Binding var pagerState: ImagePagerState
    let imageUrl: URL?
    let pageSize: CGSize
    let onDismiss: () -> Void
    
    var body: some View {
        // 📝 B/43では画像の表示に Nuke (LazyImage) を使用している
        // https://github.com/kean/Nuke
        LazyImage(url: imageUrl) { state in
            if case .success(let response) = state.result {
                let imageSize = response.image.size
                let widthFitSize = CGSize(
                    width: pageSize.width,
                    height: imageSize.height * (pageSize.width / imageSize.width)
                )
                let heightFitSize = CGSize(
                    width: imageSize.width * (pageSize.height / imageSize.height),
                    height: pageSize.height
                )
                let fitImageSize = widthFitSize.height > pageSize.height ? heightFitSize : widthFitSize
                Image(uiImage: response.image)
                    .resizable()
                    .scaledToFit()
                    .frame(width: pageSize.width, height: pageSize.height)
                    .modifier(
                        ImageGestureModifier(
                            pageSize: pageSize,
                            imageSize: fitImageSize,
                            onDraggingOver: {
                                pagerState.moveToDesiredOffset(pageSize: pageSize, additionalOffset: $0)
                            },
                            onDraggingOverEnded: { predictedEndTranslation in
                                // ✅ 水平方向のドラッグ操作が完了した後、 `predictedEndTranslation` (慣性を考慮した移動量)を基に前後のページへ移動する
                                let scrollThreshold = pageSize.width / 2.0
                                withAnimation(.easeOut) {
                                    if predictedEndTranslation.width < -scrollThreshold {
                                        pagerState.scrollToNextPage(pageSize: pageSize)
                                    } else if predictedEndTranslation.width > scrollThreshold {
                                        pagerState.scrollToPrevPage(pageSize: pageSize)
                                    } else {
                                        pagerState.moveToDesiredOffset(pageSize: pageSize)
                                    }
                                }
                                
                                // 垂直方向のドラッグ操作が完了した後、 `predictedEndTranslation` を基に必要に応じて画面を閉じる
                                let dismisssThreshold = pageSize.height / 4.0
                                if abs(predictedEndTranslation.height) > dismisssThreshold {
                                    withAnimation(.easeOut) {
                                        pagerState.invokeDismissTransition(
                                            pageSize: pageSize,
                                            predictedEndTranslationY: predictedEndTranslation.height
                                        )
                                    }
                                    onDismiss()
                                }
                            },
                            onDraggingOverCanceled: {
                                pagerState.moveToDesiredOffset(pageSize: pageSize)
                            }
                        )
                    )
            }
        }
    }
}

private struct ImagePagerState {
    private(set) var pageCount: Int
    private(set) var currentIndex: Int
    private(set) var offset: CGSize = .zero

    private var prevIndex: Int {
        max(currentIndex - 1, 0)
    }
    private var nextIndex: Int {
        min(currentIndex + 1, pageCount - 1)
    }

    init(pageCount: Int, initialIndex: Int = 0) {
        self.pageCount = pageCount
        self.currentIndex = initialIndex
    }

    mutating func scrollToPrevPage(pageSize: CGSize) {
        currentIndex = prevIndex
        moveToDesiredOffset(pageSize: pageSize)
    }

    mutating func scrollToNextPage(pageSize: CGSize) {
        currentIndex = nextIndex
        moveToDesiredOffset(pageSize: pageSize)
    }

    mutating func invokeDismissTransition(pageSize: CGSize, predictedEndTranslationY: CGFloat) {
        moveToDesiredOffset(
            pageSize: pageSize,
            additionalOffset: CGSize(width: 0, height: predictedEndTranslationY)
        )
    }

    mutating func moveToDesiredOffset(pageSize: CGSize, additionalOffset: CGSize = .zero) {
        offset = CGSize(
            width: -pageSize.width * CGFloat(currentIndex) + additionalOffset.width,
            height: additionalOffset.height
        )
    }
}

Jetpack Compose

✨ ここから先で紹介する Jetpack Compose の各コード例では、以下の資料や実装を大変に参考にさせていただいています。

engawapg.net

speakerdeck.com

続いて Jetpack Compose におけるプレビュー画面の実装を紹介していきます。

まず初めにドラッグやピンチイン操作を検出する PointerInputScope#detectTransformGestures をカスタマイズした実装を用意します。標準の実装と異なるのは “ジェスチャーの開始・終了をコールバックで受け取れる” “タッチイベントを消費するかをコールバックの戻り値で指定できる” の2点です。

suspend fun PointerInputScope.detectTransformGestures(
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, uptimeMillis: Long) -> Boolean,
    onGestureStart: () -> Unit = {},
    onGestureEnd: () -> Unit = {},
) {
    awaitEachGesture {
        val touchSlop = TouchSlop(viewConfiguration.touchSlop)
        awaitFirstDown(requireUnconsumed = false)
        onGestureStart()
        do {
            val event = awaitPointerEvent()
            val canceled = event.changes.any { it.isConsumed }
            if (!canceled) {
                val zoomChange = event.calculateZoom()
                val panChange = event.calculatePan()

                if (touchSlop.isPast(event)) {
                    val centroid = event.calculateCentroid(useCurrent = false)
                    if (zoomChange != 1f || panChange != Offset.Zero) {
                        val uptimeMillis = event.changes[0].uptimeMillis
                                                
                        // ✅ コールバックの戻り値が true である場合のみタッチイベントを消費する
                        val isConsumed = onGesture(centroid, panChange, zoomChange, uptimeMillis)
                        if (isConsumed) {
                            event.changes.forEach {
                                if (it.positionChanged()) {
                                    it.consume()
                                }
                            }
                        }
                    }
                }
            }
        } while (!canceled && event.changes.any { it.pressed })

        onGestureEnd()
    }
}
    
/**
 * ジェスチャーの移動量がごく少量の場合は `onGesture` のコールバックを発火しないようにするための実装
 */
private class TouchSlop(private val threshold: Float) {
    private var zoom = 1f
    private var pan = Offset.Zero
    private var isPast = false

    fun isPast(event: PointerEvent): Boolean {
        if (isPast) {
            return true
        }

        zoom *= event.calculateZoom()
        pan += event.calculatePan()
        val zoomMotion = abs(1 - zoom) * event.calculateCentroidSize(useCurrent = false)
        val panMotion = pan.getDistance()
        isPast = zoomMotion > threshold || panMotion > threshold
        return isPast
    }
}

カスタマイズした PointerInputScope#detectTransformGestures を用いて、プレビュー画面を実装します。これまた長大なコード例ですが、ポイントは GestureState#canConsumeGesture において、画像端を超えてドラッグした場合はタッチイベントを消費しないように考慮していることです。

必要に応じて親Viewにもタッチイベントが伝播するため、 HorizontalPager を用いて前後のページへの移動を簡潔に実装できます。(以下の例では垂直方向へのスワイプで画面を閉じるために VerticalPager も使用しています)

@Composable
fun ImagePager(
    imageUrls: List<String>,
    onDismiss: () -> Unit
) {
    // 垂直方向へのスワイプで画面を閉じるために `VerticalPager` も用いている
    // この部分を `swipeable` ( `anchoredDraggable` ) に置き換えても実装は可能
    val verticalPagerState = rememberPagerState(1)
    val horizontalPagerState = rememberPagerState(0)
    VerticalPager(
        count = 3,
        modifier = Modifier.fillMaxSize().background(Color.Black),
        state = verticalPagerState,
    ) { verticalPage ->
        if (verticalPage == 1) {
            HorizontalPager(
                count = imageUrls.size,
                modifier = Modifier.fillMaxSize(),
                state = horizontalPagerState
            ) { horizontalPage ->
                ImagePagerPage(imageUrl = imageUrls[horizontalPage])
            }
        }
    }

    LaunchedEffect(verticalPagerState.currentPage) {
        if (verticalPagerState.currentPage != 1) {
            onDismiss()
        }
    }
}

@Composable
private fun ImagePagerPage(imageUrl: String) {
    val gestureState = remember { GestureState() }
    val scope = rememberCoroutineScope()

    // 📝 B/43では画像の表示に Coil (AsyncImage) を使用している
    // https://coil-kt.github.io/coil/compose/
    AsyncImage(
        model = imageUrl,
        modifier = Modifier.fillMaxSize()
            .clipToBounds()
            .onSizeChanged { size ->
                gestureState.layoutSize = size.toSize()
            }
            .pointerInput(Unit) {
                detectTransformGestures(
                    onGestureStart = { gestureState.onGestureStart() },
                    onGesture = { centroid, pan, zoom, uptimeMillis ->
                        // ✅ 画像端を超えてドラッグしようとしている場合は false を返すことでタッチイベントを消費しない
                        val canConsume = gestureState.canConsumeGesture(pan = pan, zoom = zoom)
                        if (canConsume) {
                            scope.launch {
                                gestureState.applyGesture(
                                    pan = pan,
                                    zoom = zoom,
                                    position = centroid,
                                    uptimeMillis = uptimeMillis,
                                )
                            }
                        }
                        canConsume
                    },
                    onGestureEnd = { scope.launch { gestureState.onGestureEnd() } }
                )
            }
            .graphicsLayer {
                scaleX = gestureState.scale
                scaleY = gestureState.scale
                translationX = gestureState.offsetX
                translationY = gestureState.offsetY
            },
        onState = { state ->
            if (state is AsyncImagePainter.State.Success) {
                gestureState.imageSize = state.painter.intrinsicSize
            }
        },
        contentDescription = null,
        contentScale = ContentScale.Fit,
    )
}

@Stable
private class GestureState {
    private var _scale = Animatable(1f).apply { updateBounds(1f, 2.5f) }
    val scale: Float
        get() = _scale.value

    private var _offsetX = Animatable(0f)
    val offsetX: Float
        get() = _offsetX.value

    private var _offsetY = Animatable(0f)
    val offsetY: Float
        get() = _offsetY.value

    var layoutSize = Size.Zero
    var imageSize = Size.Zero

    private var eventConsumingState = EventConsumingState.Idle

    private val velocityTracker = VelocityTracker()
    private val velocityDecay = exponentialDecay<Float>()
    private var shouldFling = true
    private var animationJob: Job? = null

    private val fitImageSize: Size
        get() = if (imageSize == Size.Zero || layoutSize == Size.Zero) {
            Size.Zero
        } else {
            val imageAspectRatio = imageSize.width / imageSize.height
            val layoutAspectRatio = layoutSize.width / layoutSize.height
            if (imageAspectRatio > layoutAspectRatio) {
                imageSize * (layoutSize.width / imageSize.width)
            } else {
                imageSize * (layoutSize.height / imageSize.height)
            }
        }

    fun onGestureStart() {
        eventConsumingState = EventConsumingState.Idle
    }

    fun canConsumeGesture(pan: Offset, zoom: Float) = when (eventConsumingState) {
        EventConsumingState.Idle -> {
            if (zoom != 1f) {
                // ズーム操作のジェスチャーは常にハンドリングする
                eventConsumingState = EventConsumingState.Active
                true
            } else if (scale == 1f) {
                // ズームしていない場合はタッチイベントを常に消費しない
                eventConsumingState = EventConsumingState.Ignore
                false
            } else {
                // ✅ 画像端を超えてドラッグしようとした場合はタッチイベントを消費しない
                // 明確に水平 or 垂直方向に動かしていない場合は無視するために、移動量の縦横の比率が充分に高い場合だけハンドリングする
                val isPanningHorizontally = abs(pan.x) / abs(pan.y) > 3
                val isPanningHorizontallyOverLowerBound = pan.x < 0 && offsetX == _offsetX.lowerBound
                val isPanningHorizontallyOverUpperBound = pan.x > 0 && offsetX == _offsetX.upperBound
                val isPanningVertically = abs(pan.y) / abs(pan.x) > 3
                val isPanningVerticallyOverLowerBound = pan.y < 0 && offsetY == _offsetY.lowerBound
                val isPanningVerticallyOverUpperBound = pan.y > 0 && offsetY == _offsetY.upperBound
                if (isPanningHorizontally && (isPanningHorizontallyOverLowerBound || isPanningHorizontallyOverUpperBound)) {
                    eventConsumingState = EventConsumingState.Ignore
                    false
                } else if (isPanningVertically && (isPanningVerticallyOverLowerBound || isPanningVerticallyOverUpperBound)) {
                    eventConsumingState = EventConsumingState.Ignore
                    false
                } else {
                    eventConsumingState = EventConsumingState.Active
                    true
                }
            }
        }
        EventConsumingState.Active -> true
        EventConsumingState.Ignore -> false
    }

    suspend fun applyGesture(pan: Offset, zoom: Float, position: Offset, uptimeMillis: Long) = coroutineScope {
        animationJob?.cancel()
        animationJob = launch {
            launch { _scale.snapTo(scale * zoom) }

            val boundX = max((fitImageSize.width * scale - layoutSize.width), 0f) / 2f
            _offsetX.updateBounds(-boundX, boundX)
            launch { _offsetX.snapTo(offsetX + pan.x) }

            val boundY = max((fitImageSize.height * scale - layoutSize.height), 0f) / 2f
            _offsetY.updateBounds(-boundY, boundY)
            launch { _offsetY.snapTo(offsetY + pan.y) }

            velocityTracker.addPosition(uptimeMillis, position)

            if (zoom != 1f) {
                shouldFling = false
            }
        }
    }

    suspend fun onGestureEnd() = coroutineScope {
        animationJob?.cancel()
        animationJob = launch {
            if (shouldFling) {
                val velocity = velocityTracker.calculateVelocity()

                val boundX = max((fitImageSize.width * scale - layoutSize.width), 0f) / 2f
                _offsetX.updateBounds(-boundX, boundX)
                launch { _offsetX.animateDecay(velocity.x, velocityDecay) }

                val boundY = max((fitImageSize.height * scale - layoutSize.height), 0f) / 2f
                _offsetY.updateBounds(-boundY, boundY)
                launch { _offsetY.animateDecay(velocity.y, velocityDecay) }
            }

            shouldFling = true

            if (scale < 1f) {
                launch { _scale.animateTo(1f) }
            }
        }
    }

    private enum class EventConsumingState {
        Idle, Active, Ignore
    }
}

各フレームワークでの実装の違い

ここまで SwiftUI / Jetpack Compose それぞれの実装例を示しつつ、主に「水平方向のスワイプで前後のページへ移動する」「垂直方向のスワイプで画面を閉じる」の振る舞いを実現するアプローチが大きく異なることを説明しました。

“イベントの消費” の概念が組み込まれたAPIが存在しない SwiftUI では単一の DragGesture を用いて、移動量を親Viewへ自前で伝播するような設計としました。一方、Jetpack Compose では低レベルなAPIに触れるからこその煩雑さはありつつも、 HorizontalPager を使用できることで シンプルなView構造で組むことができました。

実装の全体像は以下の Gist にもまとめてあります。

おわりに

このエントリーでは "明細への画像添付" におけるプレビュー画面の実装について紹介しました。両フレームワークでの実装を担当しましたが、設計や考え方を流用できる部分とできない部分がさまざまあり興味深く実装を進められました。

このような構成の画像のプレビュー画面は多くのアプリで実装する場面があることでしょう。SwiftUI / Jetpack Compose での実装例はまだまだ少ないと思われるので、これから実装を始める方へ少しでも参考になれば幸いです。

今回、題材とした “明細への画像添付” 機能は、先日リリースした新しいメンバーシッププラン「B/43プラス」で利用できます。B/43プラスの開発メンバーが登壇するトークイベントも開催予定なのでぜひご参加ください ✨

b43.jp smartbank.connpass.com

参考資料


スマートバンクでは一緒に B/43 を作り上げていくメンバーを募集しています!カジュアル面談も受け付けていますので、お気軽にご応募ください 🙌 smartbank.co.jp

*1:iOSの写真アプリに倣った挙動。AndroidのGoogleフォトアプリなど、拡大表示中は前後のページへの移動がそもそもできないような例も存在した。

*2:clickable / draggable など何らかのジェスチャーの検出のための Modifier はすべて内部的に pointerInput に依存しています。

We create the new normal of easy budgeting, easy banking, and easy living.
In this blog, engineers, product managers, designers, business development, legal, CS, and other members will share their insights.