はじめに
今回は、ある課題解決の道筋の記録を、自身向けにまとめておこうとの思いに起因します。
尚、2023年当時下書き保存していたものの棚卸しであり、今見返すと実装的に疑問がある点、内容的には少し過去の内容になるかもしれない点をご了承下さい。
私について
親族の仕事の手伝いなどでここ数年Swiftから少し離れ気味ではあったのですが、興味を失ったわけでは全くなく、たびたび見る技術ブログやiOSDCなどで寧ろ最新技術への興味は依然尽きないので再び活動していきます。
実務をしているわけではない個人開発者です。
iOSDC JAPAN2023
私はiOSDC JAPAN2023にて初めてLTをさせて頂きました。
『UICompositionalLayoutの軽い紹介を行い、それにUIPageControl
を設置して水平方向のスクロール検知を行い、UIPageControl側にアイテムの現在地を伝える』と言った内容でした。
朧げながらに振り返ると5分に詰め込められなかった追記や、その後の実装がいくつかあるため個人開発のアプリを例に記録します。
今回はUICompositionalLayoutについての詳しい説明は省きます。
iOSDC2023のパンフレットを手元にお持ちでしたら宇佐見 公輔さんの記事がとても詳しくわかりやすいです。
↓以下でも少しだけUICompositionalLayoutの導入部分触れています。
(UIPageControlの名称をUISegmentedControlと勘違いする失態あり)
抑えておくべき前提条件
UICollectionViewの基本構造
UICollectionViewは、基本的に一方向のスクロールを前提とした設計になっています。
具体的には、UICollectionViewは1枚のUIScrollViewを持ち、
スクロール方向は一方向(縦または横)になります。
CompositionalLayoutでの縦+横スクロールの実現
しかし、CompositionalLayoutのorthogonalScrollingBehaviorを使用すると、
縦スクロール内に横スクロールセクションを配置できます。
これはどう実現されているのか?
orthogonalScrollingBehaviorを有効にしたセクションだけ、内部的に
_UICollectionViewOrthogonalScrollerEmbeddedScrollViewという別のUIScrollViewが自動生成されます。
つまり:
- 全体:1つのUIScrollView(縦方向)
- 横スクロールセクション:内部で自動生成される独立したUIScrollView(横方向)
という二重構造になっています。
ポイント:CompositionalLayoutで縦+横を組み合わせると、
内部的には複数のUIScrollViewが自動生成されますが、
これらは非公開のため直接アクセスできません。
内部の横スクロール用UIScrollViewは非公開クラスであり、公開APIから直接はアクセスできません。
そのため、scrollViewWillBeginDraggingなどの標準的なUIScrollViewDelegateのメソッドでは、
この横スクロールを検知することができません。
理由:
UICollectionViewDelegateのscrollView〜系メソッドは、
全体のUIScrollView(縦方向)のイベントのみを通知するためです。
横スクロールを検知する方法として、以下のアプローチが考えられます:
非推奨:非公開クラスに直接アクセス
例えば:
- ビュー階層から_UICollectionViewOrthogonalScrollerEmbeddedScrollViewを探索する
- そのScrollViewに対してcontentOffsetの監視(KVO / Combine / Rx)を行う
などによって横スクロールのタイミングを把握できると思われますが、
非公開クラスへの依存はややこしそう。
そもそもですが、横スクロールをUICollectionViewLayoutのカスタムクラスで実現した場合、
UIScrollViewDelegateで検知はできそうですが、実装コストが高くなりがち。
解決策:visibleItemsInvalidationHandlerを使用
実戦では、NSCollectionLayoutSectionが提供するvisibleItemsInvalidationHandlerを使うのが安全で標準的です。
メリット:
- 内部の横スクロール用UIScrollViewに直接アクセスする必要がない
- 安全で保守性が高い
UIPageControlとスクロール検知(CompositionalLayout)
せっかくNSCollectionLayoutBoundarySupplementaryItemクラスが用意されているので、
フッターを作って PageControl を設置します。
次に、水平方向のスクロールを検知してPageControlに伝えます。
visibleItemsInvalidationHandler の簡単な説明
- NSCollectionLayoutSectionに生えているコールバック
- スクロールやレイアウトが再評価されたタイミングで呼ばれる
- 可視アイテム・contentOffset・environmentが取得できる
- 内部の横スクロール用 UIScrollView に直接アクセスする必要がない
実際の動作イメージ
「CollectionView の中央」と「各アイテムの中央」の距離を比較し、
最も近いアイテム = 表示中のページ と判断します。
コード例
section.visibleItemsInvalidationHandler = { items, offset, environment in
// セルのみ(フッターなどを除外)
let cells = items.filter { $0.representedElementCategory == .cell }
guard !cells.isEmpty else { return }
// environment.container.effectiveContentSize.width を使うことで
// 回転や SafeArea などにも強い
let width = max(environment.container.effectiveContentSize.width, 1)
// 中央位置を算出
let centerX = offset.x + width / 2
// 中央に最も近いセルの index を取得
let closestIndex = cells.min(by: {
abs($0.frame.midX - centerX) < abs($1.frame.midX - centerX)
})?.indexPath.item ?? 0
// PageControl へ反映(ViewController や ViewModel 経由が望ましい)
onPageChanged(closestIndex)
}
縦スクロール時にはどうなる?
visibleItemsInvalidationHandlerは縦のスクロールにも純粋に反応します。
そのため、縦スクロール時に横スクロールの判定が誤動作する可能性があるため今回は以下の様な制御をかけます。
if abs(offset.y - previousOffset.y) > offsetThreshold {
print("縦スクロールを無視")
previousOffset.y = offset.y
return
}
補足:UIPanGestureRecognizer でも応用できます
アイテム側から PageControl 更新のフローにしたい場合は、
UIPanGestureRecognizer を使っても同様の考え方で実装できます。
iOSDC2023当日
なんとか時間内に発表を終え、リアルタイムに頂いたXのポストを眺めると、
『PageControlはタップすると隣のページに移動できる機能がある』との助言を頂く。
なるほど、往路があるということは復路もある。
この機能、恥ずかしながら知りませんでした。
という事でさっそく復路を実装すべく延長戦。
@objc func pageControlValueChanged(sender: UIPageControl) {
//一部省略
// コレクションビューのアイテム数を確認して範囲外を防止
let numberOfItems = collectionView.numberOfItems(inSection: sectionIndex)
guard itemIndex < numberOfItems else { return }
// 目的のインデックスへスクロール
let targetIndexPath = IndexPath(item: itemIndex, section: sectionIndex)
collectionView.scrollToItem(at: targetIndexPath, at: .centeredHorizontally, animated: true)
}
単純にpageControlの値の変化を発火点としてscrollToItemでアイテム側をpageControlの値の位置へスクロールさせます。
上記で動作確認すると起動時始点までスクロールが飛んで戻ってしまう挙動を確認
毎回見事に一番上までにぶ一気に飛んで戻る挙動からcollectionView.isPagingEnabledをtrueに設定した事を思い出します。
isPagingEnabledとは
ScrollViewやCollectionViewでページ単位でのスクロールを有効にするためのプロパティです。
これをtrueに設定すると、ユーザーがスクロール操作を行った際に、コンテンツは1ページずつスクロールされ、スクロールが途中で止まることなく、以下の様にページ毎にピタッと止まります。
怪しいです。
試しにfalseに戻すとやはり正常に動作します。
isPagingEnabledはUI周りのプロパティの為、詳しい内部実装がわかりませんが、
おそらく原因はisPagingEnabledプロパティの制御にあるとの仮説を建て、今回は応急的処置で対応します。
以下で実装するcollectionView.isPagingEnabledのオン・オフをスクロールの前後で頻繁に切り替えるいわゆるトグル実装は、タイミングの問題や状態の不整合が発生する可能性が高いため、一般的に推奨される方法ではないと考えていますが、当時はisPagingEnabledの動作を優先したかった為できるだけ安全面を考慮した実装を目指しています。
あくまで一つの選択肢として捉えて頂くと幸いです。
@objc func pageControlValueChanged(sender: UIPageControl) {
//先述部分省略
// スクロール前に isPagingEnabled を false に
collectionView.isPagingEnabled = false
collectionView.scrollToItem(at: targetIndexPath, at: .centeredHorizontally, animated: !self.isScrubbing)
// スクロール後isPagingEnabledをtrueに戻す
collectionView.isPagingEnabled = true
}
スクロール中にcollectionView.isPagingEnabled の変更は可能な限り回避したい
上記で一応動作しますが、当然気になる点があります。
UIKitのイベント処理はメインスレッド上でシリアルに行われるため、UIイベントは基本的に一度に1つしか処理されません。
ただscrollToItem(at:animated:) のような非同期的なアニメーションが含まれると、スクロールの完了を待たずに次のイベントがキューに入る可能性はあります。
@objc func pageControlValueChanged の連打による中身の整合性が失われるパターンは、内部に非同期処理がある場合のみです。内部に非同期処理があると、複数のタスクが並列に実行され、タイミングによっては前後するため、整合性が失われる可能性があります。
このため、スクロール中に新たなスクロール要求が来た場合、isPagingEnabled の状態が不整合になるリスクがあります。
よって、スクロール完了後に確実に isPagingEnabled = true に戻すと言った処理を実装する必要があると考えます。
何よりバグの原因となったisPagingEnabledプロパティをスクロールと競合状態に晒すのは嫌だなというのがあります。
Swift Concurrencyを使ってみます
準備:スクロール停止状態の検証
先ずは何を持ってスクロール停止と定義するかです。
先述の通り、UICollectionViewDelegate の scrollViewDidEndScrollingAnimation メソッド等は今回の横スクロールの検知には通用しないので、スクロール完了の検知は自らロジックを考える必要があります。
例えば時間で止まっているだろうとアバウトに判定するなどあると思いますが、今回はもう少し具体性が欲しいところです。
スクロール停止判定ロジック(一部省略)
func LargeSectionPageUpdater(for section: Int, recommendationArticleViewModel: RecommendationArticleViewModelType, recommendViewBounds: CGRect, updatePageIndex: @escaping (Int) -> Void) -> NSCollectionLayoutSection {
let sectionLayout = layoutBuilder.buildLargeHorizontalSectionWithFooter(recommendViewBounds: recommendViewBounds)
// ① 前回のハンドラー呼び出し時間を保持
var lastHandlerCallTime = Date()
// ② スクロール停止を検知するためのタイマー
var scrollStopTimer: Timer?
sectionLayout.visibleItemsInvalidationHandler = { [weak self] visibleItems, offset, environment in
guard let self = self, !visibleItems.isEmpty else { return }
// ③ 現在の時間を取得し、前回の呼び出しからの経過時間を計算
let currentTime = Date()
let timeSinceLastCall = currentTime.timeIntervalSince(lastHandlerCallTime)
// ④ 今回の処理時点の時間を保持
lastHandlerCallTime = currentTime
print("前回より: \(timeSinceLastCall) 秒後")
// ⑤ 既存のタイマーを無効化し、リセット
scrollStopTimer?.invalidate()
scrollStopTimer = nil
// ⑥ 新しいタイマーを0.100秒後にセット
scrollStopTimer = Timer.scheduledTimer(withTimeInterval: 0.100, repeats: false) { [weak self] _ in
guard let self = self else { return }
// ⑦ スクロールが停止したことをViewModelに通知
print("停止判定")
recommendationArticleViewModel.input.largeSectionisScrollingObserver.onNext(())
}
}
return sectionLayout
}
visibleItemsInvalidationHandler内部にタイマーを設置してスクロール検知の頻度の計測します。
計算された経過時間がtimeSinceLastCallでプリントされ、前回のハンドラー呼び出しからの間隔を表示しています。
実行の結果スクロールは初速からは約0.016秒間隔でハンドラーが呼ばれていて終速に向けておよそ0.016秒から0.080秒の間の間隔で呼ばれることが分かりました。
今回は少し余裕を持って0.100秒経過を停止と判定することとします。
準備が整いました。
ページコントロールの競合防止と直列処理
主要プロパティとフラグの説明
アプリケーションの正常な動作を確保するために、2つのプロパティと1つのフラグで実現します。
今回の場合、指のスワイプによって不要な onNext が反応することは好ましくないため、ページコントロールによるタップにフラグを設置して、指スワイプは弾きます。
1. collectionView.isPagingEnabled
- 役割: 今回の問題の発生点である collectionView のプロパティ
- 制御方針: 切り替えは確実にスクロール停止時に行いたい
2. superview?.isUserInteractionEnabled
- 役割: 直列化のために強引であるが採用したもの(理由は後述)
- 制御方針: 画面全体のユーザー操作をロック
3. pageControlIsChangingObserver(フラグ)
- 役割: visibleItemsInvalidationHandler は指のスワイプだろうと scrollToItem() によるスワイプだろうと、アイテムの動きに純粋に反応します
- 制御方針: 今回はページコントロール経由の時のみ通過させる
実装方針の検討
連打を許容するか?
結論:直列化する方向性を採用
連打許容時の懸念点
-
プロパティ操作の競合
- 停止後に即スクロールすると、連打で溜まった非同期処理後のプロパティ操作が競合する
- スクロールが停止する前にプロパティが戻ってバグを確認済み
-
無駄な処理の重複
- 連打によって溜まったタスクが一気に解放される
- defer部分が繰り返し呼ばれ、同一プロパティへの無意味な上書きが発生
-
スクロールミス
- 高速連打による scrollToItem のスクロールミス
- アイテムが動いていない場合、スクロール停止シグナルを永遠に待つ
タイムアウトの設定
スクロールミスや予期せぬシグナルの無反応に備えて 1.2秒のタイムアウト を設定:
group.addTask {
try await Task.sleep(nanoseconds: 1_200_000_000)
throw TimeoutError(message: "Scroll completion signal not received within 1.2 seconds")
}
直列化の実装方法の検討
1. 単純なフラグによる直列化(不採用)
却下理由:
- フラグを立てても、ページコントロール自体はタップ可能
- ページコントロールの値だけが進んでしまう
2. isUserInteractionEnabledで直列化(不採用)
UI要素(sender=ボタンやページコントロールなど)
を一時的にタップできなくするプロパティ
sender.isUserInteractionEnabled = false
却下理由:
- 確率は低いが、別インスタンスの表示画面内の他のページコントロールはタップ可能
- そもそもcollectionView.isPagingEnabledは画面全体の共通プロパティのため、今回の様に複数のページコントロールを設置する実装である場合競合リスクは残る
- ページコントロールをロックしても画面自体がスクロール可能
- 該当ページコントロールを画面外にスクロールされると、visibleItemsInvalidationHandlerでアイテムの停止を判定する場合にスクロール判定が返ってこず、タイムアウト待ちになる可能性がある
3. superview?.isUserInteractionEnabledで直列化(疑問を持ちながらも一旦採用)
superview?.isUserInteractionEnabled = false
採用理由:
- 画面全体にロックをかけるのは気が引けるが、実装上コストと以下の点を考慮して今回は一旦許容
- ページコントロールによるスクロールのみの動作である
- タイムアウトが設定されているため、最大でも1.2秒である
処理の流れ
1. UIPageControl タップイベントの発生
ユーザーが UIPageControl をタップ → pageControlValueChanged(sender:)が呼び出される
2. ユーザー操作とスクロールの一時制限
superview?.isUserInteractionEnabled = false
collectionView.isPagingEnabled = false
viewModel.input.pageControlIsChangingObserver.onNext(true)
処理内容:
- isUserInteractionEnabled = falseでユーザーの他の操作を制限
- collectionView.isPagingEnabled = false で UICollectionView の自動ページング機能を無効化
- ViewModel へ通知:pageControlIsChangingObservableが trueを流し、「ページコントロール変更中」の状態を保持
3. 目的のセルへスクロール開始
collectionView.scrollToItem(at: targetIndexPath, at: .centeredHorizontally, animated: true)
処理内容:
- 指定した targetIndexPath までスクロールを開始
4. スクロール完了待ち処理の開始
Task {
do {
defer {
viewModel.input.pageControlIsChangingObserver.onNext(false)
collectionView.isPagingEnabled = true
superview?.isUserInteractionEnabled = true
}
try await viewModel.input.waitForScrollCompletionOrTimeout()
}
catch {
// エラー処理
}
}
処理内容:
- 非同期タスク(Task { ... })を開始
- waitForScrollCompletionOrTimeout() を呼び出し、「スクロール完了」または「タイムアウト」を待機
- defer ブロックで処理完了時に必ず実行されるコードを設定
- isPagingEnabled を true に戻す
- isUserInteractionEnabled を true に戻す
- pageControlIsChangingObserver に falseを通知し、「変更中」の状態を解除
5. ViewModel でのスクロール完了またはタイムアウトの待機
waitForScrollCompletionOrTimeout() は、以下の2つのタスクのいずれかが完了するまで待機します。
タスク1:スクロール完了通知を待つ
group.addTask {
for try await _ in self.scrollCompletionObservable
.take(1)
.asSingle()
.asObservable()
.asAsyncStream() {
return
}
}
処理内容:
- scrollCompletionObservable から通知を待つ
- ViewModel が scrollCompletionObserver.onNext(()) を流すとスクロール完了が通知される
- 通知を受け取るとこのタスクは完了
タスク2:1.2秒のタイムアウト待機
group.addTask {
try await Task.sleep(nanoseconds: 1_200_000_000)
throw TimeoutError(message: "Scroll completion signal not received within 1.2 seconds")
}
処理内容:
- Task.sleep により1.2秒待機
- 待機中にスクロール完了通知が来ない場合は TimeoutError をスロー
タスクの結果取得
try await group.next()
処理内容:
- 最初に完了したタスクの結果を取得
- どちらかのタスクが完了した時点で、他のタスク(未完了のタスク)は group.cancelAll()によりキャンセルされる
6. タスク完了後の後処理
defer ブロックの実行
タスクが完了またはタイムアウトによりエラーが発生すると、defer ブロックが実行される
- isPagingEnabled を true に戻す
- isUserInteractionEnabled を true に戻す
- pageControlIsChangingObserver に false を通知
全体のフローまとめ
- UIPageControl がタップされる → pageControlValueChanged が呼び出される
- ユーザー操作とページング機能を一時的に無効化
- 目的のセルへスクロール開始
- スクロール完了待機処理(非同期タスク)が開始
- スクロール完了通知または1.2秒のタイムアウトを待機
- 最初に完了したタスクにより後処理が実行
- UI の操作を再び有効化し、状態をリセット
この流れにより、ページコントロールのタップからスクロールの完了まで取り急ぎ制御できました。
コード全体
FooterView クラス
@objc func pageControlValueChanged(sender: UIPageControl) {
// 一部省略
superview?.isUserInteractionEnabled = false
collectionView.isPagingEnabled = false
viewModel.input.pageControlIsChangingObserver.onNext(true)
// 一部省略
collectionView.scrollToItem(at: targetIndexPath, at: .centeredHorizontally, animated: true)
Task {
do {
defer {
viewModel.input.pageControlIsChangingObserver.onNext(false)
collectionView.isPagingEnabled = true
superview?.isUserInteractionEnabled = true
}
try await viewModel.input.waitForScrollCompletionOrTimeout()
}
catch {
// エラー処理
}
}
}
ViewModel と bind
func bindViewModel(_ viewModel: RecommendationArticleViewModelType) {
self.viewModel = viewModel
// インスタンスの独立性により merge
Observable.merge(
viewModel.output.largeSectionisScrollingObservable,
viewModel.output.smallSectionisScrollingObservable
)
.withLatestFrom(viewModel.output.pageControlIsChangingObservable)
.filter { $0 } // pageControlIsChanging が true の時のみ通過
.subscribe(onNext: { _ in
viewModel.input.scrollCompletionObserver.onNext(())
})
.disposed(by: disposeBag)
}
処理内容:
- visibleItemsInvalidationHandler は指のスワイプでも scrollToItem() でもアイテムの動きに純粋に反応する
- pageControlIsChangingObservable が true の時のみフィルターを通過させることで、ページコントロール経由のスクロールのみを検知
ViewModel クラス
func waitForScrollCompletionOrTimeout() async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
// 最初のタスクが完了した後、残りのタスクをキャンセルして次の処理へ
defer {
group.cancelAll()
}
// タスク1: scrollCompletionObservable からの通知を待機
group.addTask {
for try await _ in self.scrollCompletionObservable
.take(1)
.asSingle()
.asObservable()
.asAsyncStream() {
return
}
}
// タスク2: タイムアウトまで1.2秒待機
group.addTask {
try await Task.sleep(nanoseconds: 1_200_000_000)
throw TimeoutError(message: "Scroll completion signal not received within 1.2 seconds")
}
// 最初に完了したタスクの結果を待機
try await group.next()
}
}
Observable を AsyncSequence に変換する拡張
RxSwift の Observable を Swift Concurrency の AsyncSequenceに変換します。
Observable は async/await と直接互換性がないため、この変換により RxSwift の非同期データストリームを Swift Concurrency の async/await で扱えるようにしています。
extension ObservableType {
func asAsyncSequence() -> AsyncThrowingStream<Element, Error> {
AsyncThrowingStream { continuation in
let disposable = self.subscribe(
onNext: { continuation.yield($0) },
onError: { continuation.finish(throwing: $0) },
onCompleted: { continuation.finish() }
)
continuation.onTermination = { @Sendable _ in
disposable.dispose()
}
}
}
}
完成動作
わかりにくいですがページコントロールをタップしています。
総括
今回の実装は、「停止を出来るだけ正確に判定し、その瞬間だけ状態を戻す」という一点を守ることで、PageControl タップ → スクロール完了 → 復帰の一連を直列化しました。
・スクロール完了の検知は visibleItemsInvalidationHandler を用い、
現実的な閾値で安定化を図る。
・pageControlIsChanging フラグで“PageControl 経由のスクロールだけを通し、
指スワイプ由来のシグナルは排除。
・Task + タイムアウト(1.2s)で取りこぼし、ミススクロール時のハング回避。
復帰処理は defer に集約し、確実に元へ戻す(isPagingEnabled / interaction / フラグ)。
しかしながらトグル制御&全体ロックは暫定策の印象
・isPagingEnabled のトグル
スクロール中に切り替えると、タイミング競合や先に戻る・戻し忘れ等のバグの温床。
今回は停止時復帰+直列化で抑えたが、推奨はされにくいやり方と感じる。
・superview?.isUserInteractionEnabled の全体ロック
そもそも画面全体を止めるのは体験の犠牲が大きい。複数 PageControl がある画面でも確実で単純だが、代償があると感じる。
今後の修正案として
トグル実装を避けisPagingEnabledに近いページ境界へスナップさせる方法や、View全体をロックせずにViewModel側でページコントロールの状態を一元管理する設計のほうがベターに感じます。
感謝
以上、業務経験のないものがあれこれ考えてのたうち回ってみた様子で御座います。
ご覧いただき、ありがとうございます。
余談
実際セクション内での水平スクロールはどう実現されているか?
AppleUIKitチームのSteve Breenさん解説のWWDC2019や公式ドキュメントを見直します。
フォーーーーと歓声が挙がります。(whyの解説は無し)
やっぱりフォーーーーです。
スクロールビュー的な動作と言ってそうです。
(scrollview likeがヒントなのかもしれません)
View Hierlcheyを見に行きます。
これが内部的に生成されるUIScrollViewのようですねー。
参考





