Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

自己紹介 Yutaka Okubo 株式会社日本経済新聞社 / Nikkei Inc. 日経電子版・紙面ビューアーアプリの開発 2

Slide 3

Slide 3 text

日経電子版と紙面ビューアー 👈日経電子版 ニュースを記事単位で読める 頻繁に機能追加・改修が行われ Compose化も進められている 紙面ビューアー 👉 ニュースを新聞画像で読める コア機能は満たされ、大きな 改修機会が少なくViewのまま 3

Slide 4

Slide 4 text

Compose First 今年の Google I/O で Compose First が明言され、 View は Maintenance Mode へ移行 https://developer.android.com/blog/posts/android-ui-development-is-compose-first 4

Slide 5

Slide 5 text

でも実は、新聞紙面 UIは複雑... ● 紙面画像を表示するだけでなく、様々な要件が実装されている ○ タップ・ピンチイン/アウト・スワイプが同時に機能する複合ジェス チャー ○ 選択した記事だけを浮かび上がらせるスポットライト表示 ○ 横向きでの見開き2面表示 ○ … ● Compose First の流れを汲み、複雑なUI要件を Compose でどこま で・どのように実現できるかの検証を行った事例を紹介 5

Slide 6

Slide 6 text

アジェンダ ● 紙面ビューアーアプリの機能と検証範囲 ● Jetpack Composeで挑む ○ 壁① 複合ジェスチャーの競合解決 ○ 壁② ポリゴン当たり判定とスポットライト描画 ○ 壁③ 適応的ページ構成の設計 ● まとめ 6

Slide 7

Slide 7 text

紙面ビューアーアプリの機能と検証範囲 7

Slide 8

Slide 8 text

紙面ビューアーアプリの機能と検証範囲 複合ジェスチャー (ダブルタップ・ピンチ・スワイプ) ● ダブルタップ ○ → 画像のズーム切り替え ● ピンチイン/アウト ○ → 画像の拡大・縮小 ● スワイプ ○ → ズーム中のパン、ページめくり 8

Slide 9

Slide 9 text

紙面ビューアーアプリの機能と検証範囲 記事のスポットライト表示 ● ダブルタップ ○ → 記事をスポットライト表示 ● シングルタップ ○ → スポットライト表示解除 9

Slide 10

Slide 10 text

紙面ビューアーアプリの機能と検証範囲 表示構成( 2面表示・ LTR) ● 横向きなどでの見開き2面表示 ● 紙面の綴じ方向に 応じたLTR(左綴じ) 表示対応 10

Slide 11

Slide 11 text

紙面ビューアーアプリの機能と検証範囲 紙面のデータモデル Edition: 媒体・版 └ Page: 面(ページ) └ Article: 記事 広告など記事データが存在し ない領域もある 11

Slide 12

Slide 12 text

紙面ビューアーアプリの機能と検証範囲 紙面のデータモデル Article: 記事 └ Region: 矩形領域 └ Point: 矩形の頂点座標 「見出しと本文」や飛び地の矩形などもあるた め、記事は複数の矩形領域を持ちうる 12

Slide 13

Slide 13 text

紙面ビューアーアプリの機能と検証範囲 紙面のデータモデル Point: 矩形の頂点座標 矩形の頂点座標は紙面画像の左上を (0, 0) とし、右方向をx軸正の向き、下方向をy軸正 の向きとしたスクリーン座標系 13

Slide 14

Slide 14 text

紙面ビューアーアプリの機能と検証範囲 検証の前提 ● 既存のデータ設計・UIの是非は棚上げし、再現可能かの検証に注力 ● 3rd Party ライブラリは導入せず自前実装 ● 使用するライブラリのバージョンは以下の通り ○ Kotlin 2.4.10 ○ Compose BOM 2026.08.00 14

Slide 15

Slide 15 text

Jetpack Composeで挑む 15

Slide 16

Slide 16 text

検証のスタート地点 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

Slide 17

Slide 17 text

壁① 複合ジェスチャーの競合解決 17

Slide 18

Slide 18 text

壁① 複合ジェスチャーの競合解決 紙面ビューアーに必要な 3つのジェスチャー 1. ダブルタップ → 画像のズーム切り替え 2. ピンチイン/アウト → 画像の拡大・縮小 3. スワイプ → ズーム中のパン、ページめくり 同じ HorizontalPager・同じ pointerInput 空間で 共存しなければならず、指の動きの奪い合い(競合)が起きる 18

Slide 19

Slide 19 text

壁① 複合ジェスチャーの競合解決 Step1: ダブルタップ ズーム状態などの保持を担うクラス ZoomPanState を準備 scale: 画像の拡大率 offset: 画像の移動量 @Stable internal class ZoomPanState { val scale = Animatable(1f) val offset = Animatable(Offset.Zero, Offset.VectorConverter) private val scaleAnimationSpec = tween(200) private val offsetAnimationSpec = tween(200) suspend fun cycleZoom() = /* 次のスライドへ */ } 19

Slide 20

Slide 20 text

壁① 複合ジェスチャーの競合解決 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

Slide 21

Slide 21 text

壁① 複合ジェスチャーの競合解決 Step1: ダブルタップ Box でダブルタップを検出し、 cycleZoom() を呼び出し val state = remember { ZoomPanState() } val scope = rememberCoroutineScope() Box( modifier = Modifier ... .pointerInput(Unit) { detectTapGestures( onDoubleTap = { scope.launch { state.cycleZoom() } }, ) }, ){ AsyncImage(...) } 21

Slide 22

Slide 22 text

壁① 複合ジェスチャーの競合解決 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

Slide 23

Slide 23 text

壁① 複合ジェスチャーの競合解決 Step1: ダブルタップ ダブルタップをすることで 1x → 2.5x → 6.25x → 1x → … とズームを切り替えられるようになった 23

Slide 24

Slide 24 text

壁① 複合ジェスチャーの競合解決 Step1: ダブルタップズームの問題点 画面上のどこをダブルタップしても 画像中央に対して拡大される Modifier.graphicsLayer の transformOrigin (どこを基準にスケールや回転を行うか)は デフォルトで TransformOrigin.Center (Composableの中心)になるため 24

Slide 25

Slide 25 text

壁① 複合ジェスチャーの競合解決 Step1: ダブルタップズームの問題点 中心を基点に 拡大した場合、 タップした位置が 画面上で動いて しまう 拡大 25

Slide 26

Slide 26 text

壁① 複合ジェスチャーの競合解決 Step1: ダブルタップズームの問題点 タップした位置が 画面上で動かな いように、 パンで補正する 拡大 & パンで補正 26

Slide 27

Slide 27 text

壁① 複合ジェスチャーの競合解決 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

Slide 28

Slide 28 text

壁① 複合ジェスチャーの競合解決 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

Slide 29

Slide 29 text

壁① 複合ジェスチャーの競合解決 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

Slide 30

Slide 30 text

壁① 複合ジェスチャーの競合解決 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

Slide 31

Slide 31 text

壁① 複合ジェスチャーの競合解決 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

Slide 32

Slide 32 text

壁① 複合ジェスチャーの競合解決 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

Slide 33

Slide 33 text

壁① 複合ジェスチャーの競合解決 Step1-2: タップ座標を 基点にダブルタップズーム ダブルタップした位置を基点に ズームがされるようになった 33

Slide 34

Slide 34 text

壁① 複合ジェスチャーの競合解決 紙面ビューアーに必要な 3つのジェスチャー 1. ✅ ダブルタップ → 画像のズーム切り替え 2. ピンチイン/アウト → 画像の拡大・縮小 3. スワイプ → ズーム中のパン、ページめくり 34

Slide 35

Slide 35 text

壁① 複合ジェスチャーの競合解決 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

Slide 36

Slide 36 text

壁① 複合ジェスチャーの競合解決 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

Slide 37

Slide 37 text

壁① 複合ジェスチャーの競合解決 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

Slide 38

Slide 38 text

壁① 複合ジェスチャーの競合解決 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

Slide 39

Slide 39 text

壁① 複合ジェスチャーの競合解決 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

Slide 40

Slide 40 text

壁① 複合ジェスチャーの競合解決 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

Slide 41

Slide 41 text

壁① 複合ジェスチャーの競合解決 Step2: ピンチイン /アウト ピンチ操作で画像の拡大・縮小ができるよう になった 41

Slide 42

Slide 42 text

壁① 複合ジェスチャーの競合解決 紙面ビューアーに必要な 3つのジェスチャー 1. ✅ ダブルタップ → 画像のズーム切り替え 2. ✅ ピンチイン/アウト → 画像の拡大・縮小 3. スワイプ → ズーム中のパン、ページめくり 42

Slide 43

Slide 43 text

壁① 複合ジェスチャーの競合解決 Step3: スワイプ ズーム中にスワイプ操作をすると、 Pager にジェスチャーイベントを奪われ ページめくりが発生するためパン操作ができてい ない 43

Slide 44

Slide 44 text

壁① 複合ジェスチャーの競合解決 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

Slide 45

Slide 45 text

壁① 複合ジェスチャーの競合解決 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

Slide 46

Slide 46 text

壁① 複合ジェスチャーの競合解決 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

Slide 47

Slide 47 text

壁① 複合ジェスチャーの競合解決 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

Slide 48

Slide 48 text

壁① 複合ジェスチャーの競合解決 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

Slide 49

Slide 49 text

壁① 複合ジェスチャーの競合解決 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

Slide 50

Slide 50 text

壁① 複合ジェスチャーの競合解決 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

Slide 51

Slide 51 text

壁① 複合ジェスチャーの競合解決 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

Slide 52

Slide 52 text

壁① 複合ジェスチャーの競合解決 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

Slide 53

Slide 53 text

壁① 複合ジェスチャーの競合解決 Step3: スワイプ ズーム中のスワイプでパン操作ができ、 ページの端に到達したときのみページめくりがさ れるようになった 53

Slide 54

Slide 54 text

壁① 複合ジェスチャーの競合解決 Step3: スワイプの問題 ズーム中のパン操作時、指を離した後すぐに移 動が止まりカクつく印象を受ける 指を離した後も勢いを残して動き続けてほしいた め、フリングアニメーションをつける 54

Slide 55

Slide 55 text

壁① 複合ジェスチャーの競合解決 Step3-2: フリングアニメーション Compose の VelocityTracker を利用して、パン操作を反映するタイミン グで位置と時刻を記録 private val panVelocityTracker = VelocityTracker() private fun trackPanVelocity(position: Offset, timeMillis: Long) { panVelocityTracker.addPosition(timeMillis, position) } 55

Slide 56

Slide 56 text

壁① 複合ジェスチャーの競合解決 Step3-2: フリングアニメーション 記録したデータをもとに指を離した瞬間の速度を計算し、animateDecay で減速させながら動かし続ける private val panVelocityTracker = VelocityTracker() private val panFlingDecay = exponentialDecay() 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

Slide 57

Slide 57 text

壁① 複合ジェスチャーの競合解決 Step3-2: フリングアニメーション ズーム中のパン操作後、フリングアニメー ションが効くようになった 57

Slide 58

Slide 58 text

壁① 複合ジェスチャーの競合解決 紙面ビューアーに必要な 3つのジェスチャー 1. ✅ ダブルタップ → 画像のズーム切り替え 2. ✅ ピンチイン/アウト → 画像の拡大・縮小 3. ✅ スワイプ → ズーム中のパン、ページめくり 58

Slide 59

Slide 59 text

壁① 複合ジェスチャーの競合解決 まとめ ● 以下の点に注意をすることでベースとなる複合ジェスチャーの挙動を 実現できた ○ 画面座標・コンテンツ座標の変換 ○ canConsumeGesture によるジェスチャー所有権の管理 ● 主に3つのクラスに責務が分離され、一方向の依存関係となった ○ ZoomPanGestureDetector: ジェスチャー検出 ■ ↓ コールバックからStateのメソッドを呼ぶ ○ ZoomPanState:状態保持 ■ ↓ 座標計算を委譲する ○ ZoomPanGeometry:座標変換 59

Slide 60

Slide 60 text

壁② ポリゴン当たり判定とスポットライト描画 60

Slide 61

Slide 61 text

壁② ポリゴン当たり判定とスポットライト描画 (再掲)記事のスポットライト表示 ● ダブルタップ ○ → 記事をスポットライト表示 ● シングルタップ ○ → スポットライト表示解除 61

Slide 62

Slide 62 text

壁② ポリゴン当たり判定とスポットライト描画 記事のスポットライト表示に必要なステップ 1. ダブルタップされた位置が記事の矩形領域内にあるかを判定(ポリゴ ン当たり判定) 2. 対象記事以外の部分を暗く描画(スポットライト描画) 62

Slide 63

Slide 63 text

壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : レイキャスティング 判定点から右方向に レイ(光線)を飛ばし、ポリ ゴンとの交差回数の偶奇 で当たり判定を行う 63

Slide 64

Slide 64 text

壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : レイキャスティング ある矩形の頂点リストに対して、判定点が矩形領域に含まれるかを判定 する関数を定義 fun List.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

Slide 65

Slide 65 text

壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : レイキャスティング 2つの連続する頂点(=辺)のy座標範囲が 判定点のy座標をまたがなければ交差しない fun List.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

Slide 66

Slide 66 text

壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : レイキャスティング 辺と水平線との交点が判定点より 右にあれば(大きければ)交差する fun List.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

Slide 67

Slide 67 text

壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : レイキャスティング 交差していると判定された辺の本数が奇数の場合には内側、 偶数の場合には外側と判定 fun List.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

Slide 68

Slide 68 text

壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : レイキャスティング “ある矩形の頂点リスト に対して、判定点が矩形領域に含まれるかを判 定する” fun List.containsPoint(point: Offset): Boolean ● 頂点リスト(レシーバ): 記事矩形領域の座標 ○ Point(x, y) であり、紙面画像の縦幅/横幅 基準の座標 ● 判定点(引数): ダブルタップされた画面上の座標 ○ screenPoint であり画像コンテンツ上の座標ですらない 本来異なる座標系であるため、そのまま比較することはできない 68

Slide 69

Slide 69 text

壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : 比率座標の導入 左上が (0, 0)、右下が (1, 1) の比率座標系に変換して扱う 69

Slide 70

Slide 70 text

壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : 比率座標の導入 Point を0~1の比率座標(normalized)に変換 internal fun Point.toNormalized(pageWidth: Float, pageHeight: Float): Offset = Offset(x / pageWidth, y / pageHeight) ● x / y: 紙面画像上での座標 ● pageWidth / pageHeight: 紙面画像の縦幅/横幅 70

Slide 71

Slide 71 text

壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : 比率座標の導入 screenPoint は contentPoint に変換後、比率座標(normalized)に変 換 71

Slide 72

Slide 72 text

壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : 比率座標の導入 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

Slide 73

Slide 73 text

壁② ポリゴン当たり判定とスポットライト描画 ポリゴン当たり判定 : 比率座標の導入 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

Slide 74

Slide 74 text

壁② ポリゴン当たり判定とスポットライト描画 スポットライト描画 画像とオーバーレイを 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

Slide 75

Slide 75 text

壁② ポリゴン当たり判定とスポットライト描画 スポットライト描画 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

Slide 76

Slide 76 text

壁② ポリゴン当たり判定とスポットライト描画 スポットライト描画 ② 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

Slide 77

Slide 77 text

壁② ポリゴン当たり判定とスポットライト描画 スポットライト描画 ダブルタップで記事のスポットライト表示がで きるようになった 77

Slide 78

Slide 78 text

壁② ポリゴン当たり判定とスポットライト描画 ダブルタップズーム・シングルタップの調整 スポットライト描画中にもダブルタップによるズーム切り替え or 他記事へ のスポットライトができるように調整 onDoubleTap = { tapOffset -> ... coroutineScope.launch { if (bounds != null) { zoomPanState.focusOnRegion(bounds.center, bounds.size) } else { zoomPanState.cycleZoom(tapOffset) } } }, 78

Slide 79

Slide 79 text

壁② ポリゴン当たり判定とスポットライト描画 ダブルタップズーム・シングルタップの調整 スポットライト描画中のシングルタップでスポットライトを解除できるように 調整 onTap = { focusedArticle = null }, 79

Slide 80

Slide 80 text

壁② ポリゴン当たり判定とスポットライト描画 ダブルタップズーム・ シングルタップの調整 スポットライト表示時のダブルタップによる ズーム切り替え・他記事へのスポットライト移 動、シングルタップによるスポットライト解除 ができるようになった 80

Slide 81

Slide 81 text

壁② ポリゴン当たり判定とスポットライト描画 まとめ ● 記事のスポットライト表示を実現できた ○ 記事データ座標と画面座標・コンテンツ座標の差分を吸収するた めの比率座標(normalized)を導入 81

Slide 82

Slide 82 text

壁③ 適応的ページ構成の設計 82

Slide 83

Slide 83 text

壁③ 適応的ページ構成の設計 (再掲)表示構成( 2面表示・ LTR) ● 横向きなどでの見開き2面表示 ● 紙面の綴じ方向に 応じたLTR(左綴じ) 表示対応 83

Slide 84

Slide 84 text

壁③ 適応的ページ構成の設計 見開き2面表示 2面表示用のデータモデル(Spread)を準備し、Page からの変換処理を 定義 data class Spread(val oddPage: Page?,val evenPage: Page?) fun List.toSpreads(): List { 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

Slide 85

Slide 85 text

壁③ 適応的ページ構成の設計 見開き2面表示 先頭だけ特別扱いし、奇数面に1面を、偶数面にnullをセット fun List.toSpreads(): List { 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

Slide 86

Slide 86 text

壁③ 適応的ページ構成の設計 見開き2面表示 残りは chunked(2) で2面ずつ機械的にペア化 fun List.toSpreads(): List { 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

Slide 87

Slide 87 text

壁③ 適応的ページ構成の設計 見開き2面表示 偶数面に最終面を、奇数面にnullをセット fun List.toSpreads(): List { 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

Slide 88

Slide 88 text

壁③ 適応的ページ構成の設計 見開き2面表示 単ページと見開き2面をUIで統一的に扱うための PageColumn を準備 data class PageColumn(val page: Page?, val offset: Float, val scale: Float) ● offset: コンテンツ全体に 対するカラムの表示開始 位置 ● scale: コンテンツ全体に 対するカラムの比率 88

Slide 89

Slide 89 text

壁③ 適応的ページ構成の設計 見開き2面表示 data class PageColumn(val page: Page?, val offset: Float, val scale: Float) 見開きの空白ページも扱うため page は nullable で定義 ● offset: コンテンツ全体に 対するカラムの表示開始 位置 ● scale: コンテンツ全体に 対するカラムの比率 89

Slide 90

Slide 90 text

壁③ 適応的ページ構成の設計 見開き2面表示 比率座標(normalized)への座標変換時に offset / scale を考慮する contentPoint = offset + localContentPoint * scale ● contentPoint: コンテンツ全 体座標 ● localContentPoint: ページ ローカル座標 90

Slide 91

Slide 91 text

壁③ 適応的ページ構成の設計 見開き2面表示 PageColumn ベースで画像表示を行うように変更 Row で囲み、単ページの場合には1カラム、見開き2面の場合には 2カラムを表示する ... Row(modifier = Modifier.fillMaxSize()) { pageColumns.forEach { pageColumn -> PageColumnImage( page = pageColumn.page, alignment = pageColumn.alignment, ... ) } } ... 91

Slide 92

Slide 92 text

壁③ 適応的ページ構成の設計 見開き2面表示 見開き2面表示が できるようになった 2面表示時にも複 合ジェスチャー・ス ポットライト表示が 動作 92

Slide 93

Slide 93 text

壁③ 適応的ページ構成の設計 LTR表示 reverseLayout = !edition.isLtr, Spread / PageColumn で左右ではなく偶奇で扱うようにしたことで HorizontalPager の reverseLayout を差し替えるだけで実現 93

Slide 94

Slide 94 text

壁③ 適応的ページ構成の設計 LTR表示 左綴じの媒体も表 示できるようになっ た 94

Slide 95

Slide 95 text

壁③ 適応的ページ構成の設計 まとめ ● 見開き2面表示・LTR表示が実現できた ○ 座標変換を実装済みの仕組みに乗せるための PageColumn と いう統一表現を導入 95

Slide 96

Slide 96 text

まとめ 96

Slide 97

Slide 97 text

まとめ ● 3つの壁それぞれで、Compose 標準APIの組み合わせだけで実現 できた ● 要件が複雑になるほど、Compose の設計自由度が効いてくる ○ 責務(Detector / Geometry / State)は分離 ○ データ表現(normalized 座標 / PageColumn)は正規化 ● 実プロダクトへは未適用であるため、パフォーマンスや段階的移行 (既存Viewとの共存)などの懸念は残る 97

Slide 98

Slide 98 text

ありがとうございました 98