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

iOS/Androidで無限循環Carousel表現を考えてみる

 iOS/Androidで無限循環Carousel表現を考えてみる

2025年5月27日に開催された、potatotips #91での登壇資料になります。

この登壇資料は、無限循環Carouselという一つのUI表現を通じて、UIKit・SwiftUI・Jetpack Composeの実装アプローチの違いを深く掘り下げた技術発表です。

核心的なメッセージは、同じUI表現でもフレームワークによって実装方針が大きく異なることを実証している点です。特に宣言的UI(SwiftUI/Jetpack Compose)の登場により、従来の命令的な実装から大きくパラダイムが変化していることを具体例で示しています。

技術的価値として、iOS18で追加された新しいSwiftUI APIを活用することで、UIScrollViewDelegateのような細かい制御が可能になり、無限循環スクロールの実装が大幅に簡素化されたことを紹介。一方でJetpack Composeでは全く異なるデータ再構成アプローチを取ることで、プラットフォーム間の設計思想の違いも明確にしています。

Avatar for Fumiya Sakai

Fumiya Sakai

May 26, 2025
Tweet

More Decks by Fumiya Sakai

Other Decks in Technology

Transcript

  1. 自己紹介 ・Fumiya Sakai ・Mobile Application Engineer アカウント: ・Twitter: https://twitter.com/fumiyasac ・Facebook:

    https://www.facebook.com/fumiya.sakai.37 ・Github: https://github.com/fumiyasac ・Qiita: https://qiita.com/fumiyasac@github 発表者: ・Born on September 21, 1984 これまでの歩み: Web Designer 2008 ~ 2010 Web Engineer 2012 ~ 2016 App Engineer 2017 ~ Now iOS / Android / sometimes Flutter
  2. 過去にUIKitを利用した無限循環Scrollを応用した例 無限循環ScrollViewを利用したTab型カテゴリー表示部分の実現に関して Categoryを選択する部分に無限循環Scrollを仕込んでいる 基本的な処理機構はUIScrollViewDelegateの活用 ※実際の配置表示についてはこの様な形を取っている UIScrollViewDelegateを活用して無限循環タブ表現部分の挙動を構築する func scrollViewDidScroll(_ scrollView: UIScrollView)

    X軸方向のOffset値の計算を利用して、無限循環スクロールの挙動を実現する 画面上に見えているCell要素数よりも多めにCell要素を配置しておく 無限循環ScrollViewを作成する際にポイントになる点 X軸方向のOffset値で一定数以上の位置まで到達時に元に戻すに実装する
  3. サムネイル画像ギャラリーの様な形を考えてみる① 画像要素が横一列に並んで自動で左方向へ進んでいく&Drag処理ができる仕様 指でのScroll & Timer処理の2つを許可 αϜωΠϧը૾ΪϟϥϦʔ GeometryReaderで実装できる?最新のModifierを活用する? ᶃ ᶄ ᶅ

    ᶆ ᶉ ᶊ ᶃ ᶄ αϜωΠϧը૾ΪϟϥϦʔ Drag処理時でも自然な繋ぎ目になる SwiftUIで実装する際に起こり得る課題を整理する ポイントになるのはしきい値を超過した際の位置変更処理をどうするかの部分 ※イメージ図解を紐解くとこの様な形になる 予め表示想定の3倍の 表示要素を確保する ՄࢹྖҬ ՄࢹྖҬ ՄࢹྖҬ 後はこの処理を 繰り返す形 Carousel要素全体の長さの2倍 を超過した場合は位置調整
  4. サムネイル画像ギャラリーの様な形を考えてみる② 自動でのCarousel処理は短い時間のTimerで表示位置を更新する事で実現する 指でのScroll & Timer処理の2つを許可 αϜωΠϧը૾ΪϟϥϦʔ ᶃ ᶄ ᶅ ᶆ

    ᶉ ᶊ ᶃ ᶄ αϜωΠϧը૾ΪϟϥϦʔ Drag処理時でも自然な繋ぎ目になる 画面全体に配置するView要素のイメージ VStack { InfiniteCarouselView(imageNames: imageNames, velocity: 0.25) } InfiniteCarouselViewで利用するState値の定義 InfiniteCarouselViewを準備してCarousel要素を分離する(velocityは動かす速度) Timerとわずかな移動距離が変更された時にViewを再度レンダリングする様な形 @State private var scrollPosition = ScrollPosition() @State private var timer = Timer .publish(every: 0.01, on: .main, in: .common) .autoconnect() @State private var x: CGFloat Scroll位置とX軸方向のOffset値 Timer処理
  5. サムネイル画像ギャラリーの様な形を考えてみる③ 計算処理におけるポイントになるのは「1セット分の要素全体の長さ」 指でのScroll & Timer処理の2つを許可 αϜωΠϧը૾ΪϟϥϦʔ ᶃ ᶄ ᶅ ᶆ

    ᶉ ᶊ ᶃ ᶄ αϜωΠϧը૾ΪϟϥϦʔ Drag処理時でも自然な繋ぎ目になる 初期化処理時とBody要素本体の構造 Initialize処理時にItem要素&隙間を合わせた全体の長さを計算して保持しておく ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: itemSpacing) { ForEach(items) { item in CarouselCard(imageName: item.imageName) .id(item.id) } } .safeAreaPadding(.horizontal) } let length = (CarouselCard.itemSize.width + itemSpacing) * CGFloat(imageNames.count) self.x = length self.carouselLength = length ScrollViewに配置するのは表示する要素の個数 別途変数に保持しておく
  6. サムネイル画像ギャラリーの様な形を考えてみる④ Timer処理で自動で位置を少しずつ動かしていく部分はこの様な形となる 指でのScroll & Timer処理の2つを許可 αϜωΠϧը૾ΪϟϥϦʔ ᶃ ᶄ ᶅ ᶆ

    ᶉ ᶊ ᶃ ᶄ αϜωΠϧը૾ΪϟϥϦʔ Drag処理時でも自然な繋ぎ目になる 付与するModifierの全容(1) 各種Modifierを利用した処理を組み合わせて条件を満たす様な自動スクロールを作成 .scrollClipDisabled() .scrollPosition($scrollPosition) .onReceive(timer) { _ in if x >= carouselLength * 2 || x <= 0 { x = carouselLength } else { x += velocity } } .onChange(of: x) { scrollPosition.scrollTo(x: x) } ①.onReceive 👉 ②.onChange 👉 ③.scrollPositionの順番で処理が実行 Body要素に付与するModifierの抜粋 Timerが更新されたらverocityの分だけ @State private var xへ加算する。 しきい値を超過した時は位置を戻す。 @State private var scrollPosition 値が更新されたらScrollPositionを動かす。
  7. サムネイル画像ギャラリーの様な形を考えてみる⑤ iOS18から利用できるModifierを利用してTimer制御やDrag処理を実行する 指でのScroll & Timer処理の2つを許可 αϜωΠϧը૾ΪϟϥϦʔ ᶃ ᶄ ᶅ ᶆ

    ᶉ ᶊ ᶃ ᶄ αϜωΠϧը૾ΪϟϥϦʔ Drag処理時でも自然な繋ぎ目になる .onScrollPhaseChange { oldPhase, newPhase in switch (oldPhase, newPhase) { case (.idle, .idle): break case (_, .interacting): timer.upstream.connect().cancel() case (_, .idle): timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect() default: break } } .onScrollGeometryChange(for: Double.self) { scrollGeometry in scrollGeometry.contentOffset.x } action: { oldValue, newValue in x = newValue } 付与するModifierの全容(2) 1. ユーザーが指でスクロールし始めた際にタイマーを停止 👉 timer.upstream.connect().cancel() 2. スクロールが止まった(idle)タイミングで再びタイマー を作成し、自動スクロールを再開 ユーザーのドラッグ操作中のスクロール位置もX軸方向のOffset値に反映
  8. 同様な表現をJetpackComposeで実現するには?① 無限循環CarouselでDrag処理なしの形で実現する事例紹介 @Composable fun <T : Any> AutoScrollingLazyRow( list: List<T>,

    modifier: Modifier = Modifier, itemContent: @Composable (item: T) -> Unit, ) { LazyRow( state = lazyListState, modifier = modifier, horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { itemsIndexed( items, key = { _, item -> item.id } ) { index, item -> itemContent(item = item.data) val lazyListState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() var items by remember { mutableStateOf(list.mapAutoScrollItem()) } private fun <T : Any> List<T>.mapAutoScrollItem(): List<AutoScrollItem<T>> { val newList = this.map { AutoScrollItem(data = it) }.toMutableList() var index = 0 if (this.size < REQUIRED_CARD_COUNT) { while (newList.size != REQUIRED_CARD_COUNT) { if (index > this.size - 1) { index = 0 } newList.add(AutoScrollItem(data = this[index])) index++ } } return newList }
  9. 同様な表現をJetpackComposeで実現するには?② 無限循環CarouselでDrag処理なしの形で実現する事例紹介 if (index == items.lastIndex) { val currentList =

    items val firstVisibleItemIndex = lazyListState.firstVisibleItemIndex val secondPart = currentList.subList(0, firstVisibleItemIndex) val firstPart = currentList.subList(firstVisibleItemIndex, currentList.size) LaunchedEffect(key1 = Unit) { coroutineScope.launch { lazyListState.scrollToItem(0, maxOf(0, lazyListState.firstVisibleItemScrollOffset - SCROLL_DX.toInt())) } } items = (firstPart + secondPart) } } } suspend fun ScrollableState.autoScroll( animationSpec: AnimationSpec<Float> = tween(durationMillis = 800, easing = LinearEasing) ) { var previousValue = 0f scroll(MutatePriority.PreventUserInput) { animate(0f, SCROLL_DX, animationSpec = animationSpec) { currentValue, _ -> previousValue += scrollBy(currentValue - previousValue) } } } LaunchedEffect(key1 = Unit) { coroutineScope.launch { while (true) { lazyListState.autoScroll() } } } } 継続的に処理をすることで無限スクロールを実施 👉 scrollByを少しずつ実行する 👉 この処理を実行することで、目に見えていた部分がそのまま先頭側に来る様にリストを再構成する 👉 表示位置がリセットされても連続感が損なわれない様に調整する
  10. 今回の登壇における参考資料やサンプルに関して サンプルを読み解いてみると結構読み応えがあって難しかった部分も多いです Auto-Scrolling Infinite Carousel in SwiftUI https://dev.to/yossabourne/auto-scrolling-infinite-carousel-in-swiftui-video-1fjm https://www.youtube.com/watch?v=xlKJQLpr7GA Android

    — Infinite Auto-Scroll with Jetpack compose https://medium.com/canopas/android-infinite-auto-scroll-with-jetpack-compose-ef8d573f8878 https://github.com/appbeyond-io/AutoScrollingInfiniteCarousel-iOS https://www.youtube.com/watch?v=kFGzSv1Y_Rk https://gitlab.com/cp-hardik-p/infiniteautoscrolling 実際にコードを動かしながらポイントになる部分を抽出して解説をしてみました: