こんにちは。スマートバンクで iOS / Android エンジニアをしている nakamuuu です。
先日リリースした新しいメンバーシッププラン「B/43プラス」では支払いの明細へ画像を添付できるようになりました!添付した画像は明細画面にサムネイル状に表示され、タップするとプレビュー画面へと遷移して画像が大きく表示されます。
今回、私はこのような画像のプレビュー機能を iOS / Android の両OSそれぞれで SwiftUI / Jetpack Compose を用いて実装しました。
このエントリーでは SwiftUI / Jetpack Compose の各フレームワークにおけるジェスチャー操作のAPIや振る舞いの差異を踏まえつつ、 “よくある画像のプレビュー画面” を実装するにあたっての知見を紹介していこうと思います。
プレビュー画面の要件
上述したプレビュー画面ではユーザーのジェスチャー操作に応じて以下の振る舞いをします。それぞれSNSアプリなどのプレビュー画面でもよくある仕様でしょう。
📝 プレビュー画面のジェスチャー操作
- ① ピンチインで画像を拡大表示する
- ② 画像が拡大された状態でのドラッグで表示位置を移動する
- ③ 水平方向のスワイプで前後のページへ移動する
- ④ 垂直方向のスワイプで画面を閉じる
SwiftUI / Jetpack Compose の各フレームワークにはユーザーのジェスチャー操作を検出する多様な Modifier があらかじめ用意されています。それぞれ個々の振る舞いを実装することはさほど難しいことではないと思います。
しかし、安直に組み込むと ② の表示位置のドラッグと ③ ④ のスワイプ操作が同時に発火するなど、意図しない動作を招きかねません。例えば以下の動画のように、画像が拡大された状態で左右にドラッグした場合、画像端に到達してから前後のページへの移動を行う必要があります*1。
このように1画面に複数のジェスチャー操作を組み合わせる場合には “どのジェスチャーがタッチイベントを処理すべきか” を意識した実装が求められます。
各フレームワークでジェスチャー操作を組み合わせる
複数のジェスチャーを組み合わせる方法を各フレームワークごとに簡単に説明していきます。
SwiftUI
ジェスチャー操作に対して優先度を指定する方法として、SwiftUIでは以下のような Modifier やGesture の関数が用意されています。
- Modifier
simultaneousGesture(Gesture)
: 同一のViewの他のジェスチャーあるいは子Viewに付与されたジェスチャーと並列で処理したいジェスチャーを指定するModifierhighPriorityGesture(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 におけるプレビュー画面の実装を紹介していきます。
まず初めにドラッグやピンチイン操作を検出する 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プラスの開発メンバーが登壇するトークイベントも開催予定なのでぜひご参加ください ✨
参考資料
- SwiftUIで画像をページングする - Qiita
- Jetpack Composeで画像をズームする その3 - 縁側プログラミング
- Jetpack Compose drag gesture and pinch gesture - Speaker Deck
スマートバンクでは一緒に B/43 を作り上げていくメンバーを募集しています!カジュアル面談も受け付けていますので、お気軽にご応募ください 🙌 smartbank.co.jp