Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Jetpack Compose で挑む新聞紙面UI ─ 複合ジェスチャー・ポリゴン記事領域・適...

Jetpack Compose で挑む新聞紙面UI ─ 複合ジェスチャー・ポリゴン記事領域・適応的ページ構成という3つの壁/droidkaigi2026

DroidKaigi 2026 ( https://2026.droidkaigi.jp/ )にて
Jetpack Compose で挑む新聞紙面UI─ 複合ジェスチャー・ポリゴン記事領域・適応的ページ構成という3つの壁 というタイトルで発表。

日時:2026年9月3日(木)10:20 ~ 11:00
登壇者:大久保 豊(Yutaka Okubo)
セッションページ:Jetpack Compose で挑む新聞紙面UI ー 複合ジェスチャー・ポリゴン記事領域・適応的ページ構成という3つの壁

More Decks by 日本経済新聞社 エンジニア採用事務局

Other Decks in Technology

Transcript

  1. Compose First 今年の Google I/O で Compose First が明言され、 View

    は Maintenance Mode へ移行 https://developer.android.com/blog/posts/android-ui-development-is-compose-first 4
  2. アジェンダ • 紙面ビューアーアプリの機能と検証範囲 • Jetpack Composeで挑む ◦ 壁① 複合ジェスチャーの競合解決 ◦

    壁② ポリゴン当たり判定とスポットライト描画 ◦ 壁③ 適応的ページ構成の設計 • まとめ 6
  3. 紙面ビューアーアプリの機能と検証範囲 紙面のデータモデル Article: 記事 └ Region: 矩形領域 └ Point: 矩形の頂点座標

    「見出しと本文」や飛び地の矩形などもあるた め、記事は複数の矩形領域を持ちうる 12
  4. 検証のスタート地点 HorizontalPager で AsyncImage を表示 HorizontalPager( state = pagerState, modifier

    = modifier.fillMaxSize(), beyondViewportPageCount = 1, reverseLayout = true, ) { index -> Box(contentAlignment = Alignment.Center, ...) { AsyncImage( model = pages[index].imageUrl, contentScale = ContentScale.Fit, ... ) } } 16
  5. 壁① 複合ジェスチャーの競合解決 紙面ビューアーに必要な 3つのジェスチャー 1. ダブルタップ → 画像のズーム切り替え 2. ピンチイン/アウト

    → 画像の拡大・縮小 3. スワイプ → ズーム中のパン、ページめくり 同じ HorizontalPager・同じ pointerInput 空間で 共存しなければならず、指の動きの奪い合い(競合)が起きる 18
  6. 壁① 複合ジェスチャーの競合解決 Step1: ダブルタップ ズーム状態などの保持を担うクラス ZoomPanState を準備 scale: 画像の拡大率 offset:

    画像の移動量 @Stable internal class ZoomPanState { val scale = Animatable(1f) val offset = Animatable(Offset.Zero, Offset.VectorConverter) private val scaleAnimationSpec = tween<Float>(200) private val offsetAnimationSpec = tween<Offset>(200) suspend fun cycleZoom() = /* 次のスライドへ */ } 19
  7. 壁① 複合ジェスチャーの競合解決 Step1: ダブルタップ cycleZoom(): scale をサイクルさせながら animateTo で更新 internal

    val ZOOM_LEVELS = listOf(1f, 2.5f, 6.25f) suspend fun cycleZoom() = coroutineScope { val currentIndex = ZOOM_LEVELS.indexOfLast { it <= scale.value } val nextIndex = (currentIndex + 1) % ZOOM_LEVELS.size launch { scale.animateTo(ZOOM_LEVELS[nextIndex], scaleAnimationSpec) } } 20
  8. 壁① 複合ジェスチャーの競合解決 Step1: ダブルタップ Box でダブルタップを検出し、 cycleZoom() を呼び出し val state

    = remember { ZoomPanState() } val scope = rememberCoroutineScope() Box( modifier = Modifier ... .pointerInput(Unit) { detectTapGestures( onDoubleTap = { scope.launch { state.cycleZoom() } }, ) }, ){ AsyncImage(...) } 21
  9. 壁① 複合ジェスチャーの競合解決 Step1: ダブルタップ AsyncImage に Modifier.graphicsLayer で scale /

    offset を反映する val state = remember { ZoomPanState() } val scope = rememberCoroutineScope() Box(...) { AsyncImage( ... modifier = Modifier .fillMaxSize() .graphicsLayer { scaleX = state.scale.value scaleY = state.scale.value translationX = state.offset.value.x translationY = state.offset.value.y }, ) } 22
  10. 壁① 複合ジェスチャーの競合解決 Step1-2: タップ座標を基点にダブルタップズーム Modifier.graphicsLayer の計算式をもとに必要なパン量を求める screenPoint = (contentPoint -

    containerCenter) * scale + containerCenter + pan • screenPoint: Container(Box) が検出する画面上のタップ座標 • contentPoint: Modifier.graphicsLayer が適用されていない状態で のContent(AsyncImage)上の座標 • containerCenter: Container(Box) の中心座標 • scale: Content(AsyncImage)の拡大率 • pan: Content(AsyncImage)の移動量 27
  11. 壁① 複合ジェスチャーの競合解決 Step1-2: タップ座標を基点にダブルタップズーム screenPoint = (contentPoint - containerCenter) *

    scale + containerCenter + pan contentPoint について整理した関数を準備 private fun screenToContent(screenPoint: Offset, scale: Float, pan: Offset): Offset = (screenPoint - pan - containerCenter) / scale + containerCenter 指定の scale / pan のもとで screenPoint(検出されたContainer上のタッ プ座標)に対応した contentPoint(デフォルト状態のContent上の座標)を 得る 28
  12. 壁① 複合ジェスチャーの競合解決 Step1-2: タップ座標を基点にダブルタップズーム screenPoint = (contentPoint - containerCenter) *

    scale + containerCenter + pan pan について整理した関数を準備 private fun panFor(screenPoint: Offset, contentPoint: Offset, scale: Float): Offset = screenPoint - (contentPoint - containerCenter) * scale - containerCenter 指定の scale のもとで contentPoint(デフォルト状態のContent上の座 標)が screenPoint(検出されたContainer上のタップ座標)に留まるため のパン量を得る 29
  13. 壁① 複合ジェスチャーの競合解決 Step1-2: タップ座標を基点にダブルタップズーム screenToContent と panFor をまとめた関数を座標変換処理を担う ZoomPanGeometory クラスを作成し定義

    fun panForZoom(screenPoint: Offset, currentScale: Float, currentPan: Offset, targetScale: Float): Offset { val contentPoint = screenToContent(screenPoint, currentScale, currentPan) return panFor(screenPoint, contentPoint, targetScale) } 現在の scale / pan のもとで screenPoint に対応した contentPoint を取 得 → targetScale でも contentPoint が screenPoint に留まるためのパ ン量を取得 30
  14. 壁① 複合ジェスチャーの競合解決 Step1-2: タップ座標を基点にダブルタップズーム 求めたパンを適用する際の可動範囲を準備 fun panBounds(scale: Float): Rect {

    val boundX = maxOf(fitContentSize.width * scale - containerSize.width, 0f) / 2f val boundY = maxOf(fitContentSize.height * scale - containerSize.height, 0f) / 2f return Rect(-boundX, -boundY, boundX, boundY) } • fitContentSize: Content(AsyncImage)を ContentScale.Fit でContainer(Box)に収めた (scale=1の)ときの実表示サイズ • containerSize: Container(Box)のサイズ 31
  15. 壁① 複合ジェスチャーの競合解決 Step1-2: タップ座標を基点にダブルタップズーム ZoomPanState.cycleZoom() で bounds を設定しつつ、scale / offset

    を更新 val targetScale = ZOOM_LEVELS[nextIndex] val bounds = geometry.panBounds(targetScale) val targetOffset = geometry.panForZoom(screenPoint, scale.value, offset.value, targetScale) .coerceIn(bounds) offset.updateBounds(bounds.topLeft, bounds.bottomRight) launch { scale.animateTo(targetScale, scaleAnimationSpec) } launch { offset.animateTo(targetOffset, offsetAnimationSpec) } 32
  16. 壁① 複合ジェスチャーの競合解決 紙面ビューアーに必要な 3つのジェスチャー 1. ✅ ダブルタップ → 画像のズーム切り替え 2.

    ピンチイン/アウト → 画像の拡大・縮小 3. スワイプ → ズーム中のパン、ページめくり 34
  17. 壁① 複合ジェスチャーの競合解決 Step2: ピンチイン /アウト ジェスチャー検出をまとめる ZoomPanGestureDetector を作成し 2本指の間隔からズーム変化率を取得するための PointerInputScope.detectZoomPanGestures()

    を定義(一部抜粋) while (event.changes.fastAny { it.pressed }) { val zoomChange = event.calculateZoom() if (zoomChange != 1f) { val centroid = event.calculateCentroid() onGesture(centroid, zoomChange) event.changes.fastForEach { if (it.positionChanged()) it.consume() } } } 35
  18. 壁① 複合ジェスチャーの競合解決 Step2: ピンチイン /アウト PointerEvent.calculateZoom() でズームの変化率を取得 変化あり(≠1f)の場合に処理を行う while (event.changes.fastAny

    { it.pressed }) { val zoomChange = event.calculateZoom() if (zoomChange != 1f) { val centroid = event.calculateCentroid() onGesture(centroid, zoomChange) event.changes.fastForEach { if (it.positionChanged()) it.consume() } } } 36
  19. 壁① 複合ジェスチャーの競合解決 Step2: ピンチイン /アウト PointerEvent.calculateCentroid() で操作している全指の重心座標を取 得し、ズーム変化率とあわせてコールバックに渡す while (event.changes.fastAny

    { it.pressed }) { val zoomChange = event.calculateZoom() if (zoomChange != 1f) { val centroid = event.calculateCentroid() onGesture(centroid, zoomChange) event.changes.fastForEach { if (it.positionChanged()) it.consume() } } } 37
  20. 壁① 複合ジェスチャーの競合解決 Step2: ピンチイン /アウト ZoomPanState で受け取った変化量をもとに scale / offset

    を更新 fun applyGesture(centroid: Offset, zoomChange: Float, scope: CoroutineScope) { val targetScale = (scale.value * zoomChange) .coerceIn(ZOOM_LEVELS.first(), ZOOM_LEVELS.last()) val bounds = geometry.panBounds(targetScale) val targetOffset = geometry.panForZoom(centroid, scale.value, offset.value, targetScale) offset.updateBounds(bounds.topLeft, bounds.bottomRight) scope.launch { scale.snapTo(targetScale) offset.snapTo(targetOffset) } } 38
  21. 壁① 複合ジェスチャーの競合解決 Step2: ピンチイン /アウト ズーム変化率から拡大率を決め、重心座標を基点に拡大・縮小 fun applyGesture(centroid: Offset, zoomChange:

    Float, scope: CoroutineScope) { val targetScale = (scale.value * zoomChange) .coerceIn(ZOOM_LEVELS.first(), ZOOM_LEVELS.last()) val bounds = geometry.panBounds(targetScale) val targetOffset = geometry.panForZoom(centroid, scale.value, offset.value, targetScale) offset.updateBounds(bounds.topLeft, bounds.bottomRight) scope.launch { scale.snapTo(targetScale) offset.snapTo(targetOffset) } } 39
  22. 壁① 複合ジェスチャーの競合解決 Step2: ピンチイン /アウト アニメーションなしでよいため scale / offset を

    snapTo で更新 fun applyGesture(centroid: Offset, zoomChange: Float, scope: CoroutineScope) { val targetScale = (scale.value * zoomChange) .coerceIn(ZOOM_LEVELS.first(), ZOOM_LEVELS.last()) val bounds = geometry.panBounds(targetScale) val targetOffset = geometry.panForZoom(centroid, scale.value, offset.value, targetScale) offset.updateBounds(bounds.topLeft, bounds.bottomRight) scope.launch { scale.snapTo(targetScale) offset.snapTo(targetOffset) } } 40
  23. 壁① 複合ジェスチャーの競合解決 紙面ビューアーに必要な 3つのジェスチャー 1. ✅ ダブルタップ → 画像のズーム切り替え 2.

    ✅ ピンチイン/アウト → 画像の拡大・縮小 3. スワイプ → ズーム中のパン、ページめくり 42
  24. 壁① 複合ジェスチャーの競合解決 Step3: スワイプ パン検出のため PointerInputScope.detectZoomPanGestures() にジェ スチャーイベント消費判定用の canConsumeGesture を追加

    while (event.changes.fastAny { it.pressed }) { val zoomChange = event.calculateZoom() val pan = event.calculatePan() if ((zoomChange != 1f || pan != Offset.Zero) && (owned || canConsumeGesture(pan, zoomChange))) { owned = true val centroid = event.calculateCentroid() onGesture(centroid, pan, zoomChange) event.changes.fastForEach { if (it.positionChanged()) it.consume() } } } 44
  25. 壁① 複合ジェスチャーの競合解決 Step3: スワイプ ズームかパンの変化量があるときには、イベント消費を判定する すでに所有権(owned)を取得している場合には消費する while (event.changes.fastAny { it.pressed

    }) { val zoomChange = event.calculateZoom() val pan = event.calculatePan() if ((zoomChange != 1f || pan != Offset.Zero) && (owned || canConsumeGesture(pan, zoomChange))) { owned = true val centroid = event.calculateCentroid() onGesture(centroid, pan, zoomChange) event.changes.fastForEach { if (it.positionChanged()) it.consume() } } } 45
  26. 壁① 複合ジェスチャーの競合解決 Step3: スワイプ private fun Modifier.zoomPanGesture(state: ZoomPanState, scope: CoroutineScope):

    Modifier = pointerInput(Unit) { var deferredToPager = false detectZoomPanGestures( canConsumeGesture = { pan, zoomChange -> when { zoomChange != 1f -> true deferredToPager -> false state.scale.value <= 1f || state.isAtEdge(pan) -> false.also { deferredToPager = true } else -> true } }, onGestureStart = { deferredToPager = false }, onGesture = { state.applyGesture(...) }, ) } 46
  27. 壁① 複合ジェスチャーの競合解決 Step3: スワイプ ピンチ操作(ズーム変化量あり)の場合、Pager に委譲する余地がない ため常に自分で消費する canConsumeGesture = {

    pan, zoomChange -> when { zoomChange != 1f -> true deferredToPager -> false state.scale.value <= 1f || state.isAtEdge(pan) -> false.also { deferredToPager = true } else -> true } }, 47
  28. 壁① 複合ジェスチャーの競合解決 Step3: スワイプ 等倍(scale<=1f)または端に到達した場合、Pager に委譲 変数で保持し、同じジェスチャー中には再判定をしない canConsumeGesture = {

    pan, zoomChange -> when { zoomChange != 1f -> true deferredToPager -> false state.scale.value <= 1f || state.isAtEdge(pan) -> false.also { deferredToPager = true } else -> true } }, 48
  29. 壁① 複合ジェスチャーの競合解決 Step3: スワイプ ズーム中(scale>1f)かつ端に到達していない場合、ZoomPanState が 所有権を握り続ける canConsumeGesture = {

    pan, zoomChange -> when { zoomChange != 1f -> true deferredToPager -> false state.scale.value <= 1f || state.isAtEdge(pan) -> false.also { deferredToPager = true } else -> true } }, 49
  30. 壁① 複合ジェスチャーの競合解決 Step3: スワイプ 「端に到達した」判定のため、ZoomPanState.isAtEdge() を定義 fun isAtEdge(pan: Offset): Boolean

    { val ratio = abs(pan.y) / abs(pan.x) val bounds = geometry.panBounds(scale.value) return when { ratio < 1f / 3f -> (pan.x < 0f && offset.value.x == bounds.topLeft.x) || (pan.x > 0f && offset.value.x == bounds.bottomRight.x) else -> false } } 50
  31. 壁① 複合ジェスチャーの競合解決 Step3: スワイプ パンの移動方向が tan(18.43°) ≒ 1/3 のときのみ端到達判定をする fun

    isAtEdge(pan: Offset): Boolean { val ratio = abs(pan.y) / abs(pan.x) val bounds = geometry.panBounds(scale.value) return when { ratio < 1f / 3f -> (pan.x < 0f && offset.value.x == bounds.topLeft.x) || (pan.x > 0f && offset.value.x == bounds.bottomRight.x) else -> false } } 判定範囲を広げると、斜め方向のパン操作によって過度にページめくり が発生する可能性がある 51
  32. 壁① 複合ジェスチャーの競合解決 Step3: スワイプ 移動方向の正負に応じて、すでに可動範囲に到達しているかを判定 fun isAtEdge(pan: Offset): Boolean {

    val ratio = abs(pan.y) / abs(pan.x) val bounds = geometry.panBounds(scale.value) return when { ratio < 1f / 3f -> (pan.x < 0f && offset.value.x == bounds.topLeft.x) || (pan.x > 0f && offset.value.x == bounds.bottomRight.x) else -> false } } 52
  33. 壁① 複合ジェスチャーの競合解決 Step3-2: フリングアニメーション Compose の VelocityTracker を利用して、パン操作を反映するタイミン グで位置と時刻を記録 private

    val panVelocityTracker = VelocityTracker() private fun trackPanVelocity(position: Offset, timeMillis: Long) { panVelocityTracker.addPosition(timeMillis, position) } 55
  34. 壁① 複合ジェスチャーの競合解決 Step3-2: フリングアニメーション 記録したデータをもとに指を離した瞬間の速度を計算し、animateDecay で減速させながら動かし続ける private val panVelocityTracker =

    VelocityTracker() private val panFlingDecay = exponentialDecay<Offset>() fun endPanGesture(scope: CoroutineScope) { val velocity = panVelocityTracker.calculateVelocity() val velocityOffset = Offset(velocity.x, velocity.y) if (velocityOffset != Offset.Zero) { scope.launch { offset.animateDecay(velocityOffset, panFlingDecay) } } } 56
  35. 壁① 複合ジェスチャーの競合解決 紙面ビューアーに必要な 3つのジェスチャー 1. ✅ ダブルタップ → 画像のズーム切り替え 2.

    ✅ ピンチイン/アウト → 画像の拡大・縮小 3. ✅ スワイプ → ズーム中のパン、ページめくり 58
  36. 壁① 複合ジェスチャーの競合解決 まとめ • 以下の点に注意をすることでベースとなる複合ジェスチャーの挙動を 実現できた ◦ 画面座標・コンテンツ座標の変換 ◦ canConsumeGesture

    によるジェスチャー所有権の管理 • 主に3つのクラスに責務が分離され、一方向の依存関係となった ◦ ZoomPanGestureDetector: ジェスチャー検出 ▪ ↓ コールバックからStateのメソッドを呼ぶ ◦ ZoomPanState:状態保持 ▪ ↓ 座標計算を委譲する ◦ ZoomPanGeometry:座標変換 59
  37. 壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : レイキャスティング ある矩形の頂点リストに対して、判定点が矩形領域に含まれるかを判定 する関数を定義 fun List<Offset>.containsPoint(point: Offset):

    Boolean { val crossingCount = indices.count { i -> val current = this[i] val next = this[(i + 1) % size] if ((current.y > point.y) == (next.y > point.y)) return@count false val intersectX = (next.x - current.x) * (point.y - current.y) / (next.y - current.y) + current.x point.x < intersect } return crossingCount % 2 == 1 } 64
  38. 壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : レイキャスティング 2つの連続する頂点(=辺)のy座標範囲が 判定点のy座標をまたがなければ交差しない fun List<Offset>.containsPoint(point: Offset):

    Boolean { val crossingCount = indices.count { i -> val current = this[i] val next = this[(i + 1) % size] if ((current.y > point.y) == (next.y > point.y)) return@count false val intersectX = (next.x - current.x) * (point.y - current.y) / (next.y - current.y) + current.x point.x < intersectX } return crossingCount % 2 == 1 } 65
  39. 壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : レイキャスティング 辺と水平線との交点が判定点より 右にあれば(大きければ)交差する fun List<Offset>.containsPoint(point: Offset):

    Boolean { val crossingCount = indices.count { i -> val current = this[i] val next = this[(i + 1) % size] if ((current.y > point.y) == (next.y > point.y)) return@count false val intersectX = (next.x - current.x) * (point.y - current.y) / (next.y - current.y) + current.x point.x < intersectX } return crossingCount % 2 == 1 } 66
  40. 壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : レイキャスティング 交差していると判定された辺の本数が奇数の場合には内側、 偶数の場合には外側と判定 fun List<Offset>.containsPoint(point: Offset):

    Boolean { val crossingCount = indices.count { i -> val current = this[i] val next = this[(i + 1) % size] if ((current.y > point.y) == (next.y > point.y)) return@count false val intersectX = (next.x - current.x) * (point.y - current.y) / (next.y - current.y) + current.x point.x < intersectX } return crossingCount % 2 == 1 } 67
  41. 壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : レイキャスティング “ある矩形の頂点リスト に対して、判定点が矩形領域に含まれるかを判 定する” fun List<Offset>.containsPoint(point:

    Offset): Boolean • 頂点リスト(レシーバ): 記事矩形領域の座標 ◦ Point(x, y) であり、紙面画像の縦幅/横幅 基準の座標 • 判定点(引数): ダブルタップされた画面上の座標 ◦ screenPoint であり画像コンテンツ上の座標ですらない 本来異なる座標系であるため、そのまま比較することはできない 68
  42. 壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : 比率座標の導入 Point を0~1の比率座標(normalized)に変換 internal fun Point.toNormalized(pageWidth:

    Float, pageHeight: Float): Offset = Offset(x / pageWidth, y / pageHeight) • x / y: 紙面画像上での座標 • pageWidth / pageHeight: 紙面画像の縦幅/横幅 70
  43. 壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : 比率座標の導入 fun screenToNormalized(screenPoint: Offset, scale: Float,

    pan: Offset): Offset = contentToNormalized(screenToContent(screenPoint, scale, pan)) private fun contentToNormalized(contentPoint: Offset): Offset = Offset( (contentPoint.x - contentTopLeft.x) / fitContentSize.width, (contentPoint.y - contentTopLeft.y) / fitContentSize.height, ) • contentTopLeft: フィット後のContentがContainer 上で左上に来る座標 • fitContentSize: Contentを ContentScale.Fit で Containerに収めた(scale=1の)ときの実表示サ イズ 72
  44. 壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : 比率座標の導入 Box に付けたジェスチャー検出用のModifierのコールバックで 比率座標による当たり判定を行い記事を特定して保持 onDoubleTap =

    { screenPoint -> val normalizedPoint = zoomPanState.screenToNormalized(screenPoint) val hitArticle = page.articles.firstOrNull { it.containsNormalizedPoint(normalizedPoint, edition.pageWidth, edition.pageHeight) } if (hitArticle != null) focusedArticle = hitArticle val bounds = hitArticle?.normalizedBounds(edition.pageWidth, edition.pageHeight) coroutineScope.launch { if (bounds != null) { zoomPanState.focusOnRegion(bounds.center, bounds.size) } } }, 73
  45. 壁② ポリゴン当たり判定とスポットライト描画 スポットライト描画 画像とオーバーレイを Box で 囲みズームやパンが同様に適 用されるように変更 focusedArticle がセットされて

    いる場合にはオーバーレイを 表示 Box( modifier = Modifier .fillMaxSize() .graphicsLayer { scaleX = zoomPanState.scale.value scaleY = zoomPanState.scale.value translationX = zoomPanState.offset.value.x translationY = zoomPanState.offset.value.y }, ){ AsyncImage(...) // 紙面画像表示用 focusedArticle?.let { article -> // スポットライト用オーバーレイ ArticleFocusOverlay(...) } } 74
  46. 壁② ポリゴン当たり判定とスポットライト描画 スポットライト描画 ArticleFocusOverlay では Canvas によりスポットライトを描画 ① 記事のポリゴン形状を Path

    で作成 val articlePath = Path() article.regions.filter { it.points.isNotEmpty() }.forEach { region -> region.points.forEachIndexed { index, point -> val contentPoint = zoomPanState.normalizedToContent( point.toNormalized(pageWidth, pageHeight) ) if (index == 0) articlePath.moveTo(contentPoint.x, contentPoint.y) else articlePath.lineTo(contentPoint.x, contentPoint.y) } articlePath.close() } 75
  47. 壁② ポリゴン当たり判定とスポットライト描画 スポットライト描画 ② ClipOp.Difference で外側だけを暗く描画 Canvas(modifier = Modifier.fillMaxSize()) {

    val articlePath = Path() // ...省略 ① のPath作成処理 val imageTopLeft = zoomPanState.normalizedToContent(Offset.Zero) val imageBottomRight = zoomPanState.normalizedToContent(Offset(1f, 1f)) clipPath(articlePath, clipOp = ClipOp.Difference) { drawRect( color = Color.Black.copy(alpha = 0.24f), topLeft = imageTopLeft, size = Rect(imageTopLeft, imageBottomRight).size, ) } } 76
  48. 壁③ 適応的ページ構成の設計 見開き2面表示 2面表示用のデータモデル(Spread)を準備し、Page からの変換処理を 定義 data class Spread(val oddPage:

    Page?,val evenPage: Page?) fun List<Page>.toSpreads(): List<Spread> { if (isEmpty()) return emptyList() val cover = Spread(oddPage = first(), evenPage = null) val rest = drop(1).chunked(2).map { chunk -> if (chunk.size == 2) Spread(oddPage = chunk[1], evenPage = chunk[0]) else Spread(oddPage = null, evenPage = chunk[0]) } return listOf(cover) + rest } 84
  49. 壁③ 適応的ページ構成の設計 見開き2面表示 先頭だけ特別扱いし、奇数面に1面を、偶数面にnullをセット fun List<Page>.toSpreads(): List<Spread> { if (isEmpty())

    return emptyList() val cover = Spread(oddPage = first(), evenPage = null) val rest = drop(1).chunked(2).map { chunk -> if (chunk.size == 2) Spread(oddPage = chunk[1], evenPage = chunk[0]) else Spread(oddPage = null, evenPage = chunk[0]) } return listOf(cover) + rest } 85
  50. 壁③ 適応的ページ構成の設計 見開き2面表示 残りは chunked(2) で2面ずつ機械的にペア化 fun List<Page>.toSpreads(): List<Spread> {

    if (isEmpty()) return emptyList() val cover = Spread(oddPage = first(), evenPage = null) val rest = drop(1).chunked(2).map { chunk -> if (chunk.size == 2) Spread(oddPage = chunk[1], evenPage = chunk[0]) else Spread(oddPage = null, evenPage = chunk[0]) } return listOf(cover) + rest } 86
  51. 壁③ 適応的ページ構成の設計 見開き2面表示 偶数面に最終面を、奇数面にnullをセット fun List<Page>.toSpreads(): List<Spread> { if (isEmpty())

    return emptyList() val cover = Spread(oddPage = first(), evenPage = null) val rest = drop(1).chunked(2).map { chunk -> if (chunk.size == 2) Spread(oddPage = chunk[1], evenPage = chunk[0]) else Spread(oddPage = null, evenPage = chunk[0]) } return listOf(cover) + rest } 87
  52. 壁③ 適応的ページ構成の設計 見開き2面表示 単ページと見開き2面をUIで統一的に扱うための PageColumn を準備 data class PageColumn(val page:

    Page?, val offset: Float, val scale: Float) • offset: コンテンツ全体に 対するカラムの表示開始 位置 • scale: コンテンツ全体に 対するカラムの比率 88
  53. 壁③ 適応的ページ構成の設計 見開き2面表示 data class PageColumn(val page: Page?, val offset:

    Float, val scale: Float) 見開きの空白ページも扱うため page は nullable で定義 • offset: コンテンツ全体に 対するカラムの表示開始 位置 • scale: コンテンツ全体に 対するカラムの比率 89
  54. 壁③ 適応的ページ構成の設計 見開き2面表示 比率座標(normalized)への座標変換時に offset / scale を考慮する contentPoint =

    offset + localContentPoint * scale • contentPoint: コンテンツ全 体座標 • localContentPoint: ページ ローカル座標 90
  55. まとめ • 3つの壁それぞれで、Compose 標準APIの組み合わせだけで実現 できた • 要件が複雑になるほど、Compose の設計自由度が効いてくる ◦ 責務(Detector

    / Geometry / State)は分離 ◦ データ表現(normalized 座標 / PageColumn)は正規化 • 実プロダクトへは未適用であるため、パフォーマンスや段階的移行 (既存Viewとの共存)などの懸念は残る 97