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

SwiftUIのGeometryReaderとScrollViewを基礎から応用まで学び直す:...

 SwiftUIのGeometryReaderとScrollViewを基礎から応用まで学び直す:設計と活用事例

本セッションは、SwiftUIにおける GeometryReader と ScrollView の基礎から応用までを体系的に整理し、複雑なUI表現をいかに設計・実装するかを具体例と共に解説したものです。

1. 背景と課題
- GeometryReaderの難しさ:座標基準の分かりづらさやデバッグの困難さ。
- ScrollViewDelegate的な発想:UIKitとの違いに戸惑い、Viewの重なりや処理トリガー整理が必要。
- サンプル不足:ちょうど良い難易度の実装例が少なく、学習・応用が難しい。

2. GeometryReaderの活用事例
- スクロールに応じたヘッダー縮小・拡大(Parallax効果・FontSize調整)。
- 背景のParallax効果(スクロール位置に応じたScale/Offset制御)。
- レスポンシブなレイアウト調整(widthに応じてGrid列数を変更)。

3. 応用的な実装例
- 日付タブ切替UI(DroidKaigiでの事例)
 - AndroidのCoordinatorLayout+TabLayoutの動きをSwiftUIで模倣。
 - GeometryReader+PreferenceKeyでOffset値を監視し、タブ背景や下線を滑らかに補間。
- お気に入り追加時のハートアニメーション
 - GeometryReaderでタップ位置を取得し、ZStackに重ねたImageを@Stateで制御。
 - DragGestureを利用してボタン操作を再現し、滑らかな動きを実装。
- 横スクロール型タブ+コンテンツ切替
 - ScrollView(.horizontal)+LazyHStackと@Stateを組み合わせて、タブとコンテンツを双方向に同期。
 - 線形補間を応用し、自然な下線移動と文字幅調整を実現。
- サムネイル自動カルーセル
 - ScrollPositionとTimerを組み合わせて無限スクロールを実現。
 - iOS18新Modifier(onScrollPhaseChange / onScrollGeometryChange)でユーザー操作と自動スクロールを自然に両立。

4. 設計の指針
- GeometryReaderは「値取り専用」に割り切る:
 - 必要最小限の値を取得し、State/ViewModelに閉じ込めて副作用を抑制。
- ScrollViewは新Modifierを主役に宣言的に扱う:
 - scrollPosition/scrollTargetLayout/scrollTargetBehaviorでページングや位置管理を簡潔に。
- 複雑なUIは「値 → 状態 → 見た目」に分解:
 - ZStackで固定物
 - ScrollView+Lazy系でリスト/タブ
 - GeometryReader値はまず状態に変換し、最終的なUIは補間やアニメーションで調整。

5. まとめ
SwiftUIはiOS17/18で強化されたScrollView系Modifierを組み合わせることで、GeometryReader頼みの実装から進化しつつある。ただし、依然として Parallax・レスポンシブ対応・複雑なタブ構造 などではGeometryReaderが重要な役割を果たす。本セッションでは、その実践的なアプローチと具体コード例を通じて、「複雑UIを整理し、保守性とパフォーマンスを両立させる」 実装の勘どころが示された。

Avatar for Fumiya Sakai

Fumiya Sakai

October 03, 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. まずは参加者の皆様にお聞きしたいと思います Question. GeometryReaderやUIScrollViewDelegateにどんなイメージがある? 1. GeometryReaderに関する印象: 難しい。(取得値による座標計算の複雑さ / 算出された値の基準がどこなのか分かりにくい / バージョン差異

    … ) UIKitに慣れていないと意外と難しい。(座標計算の複雑さやUITableView / UICollectionViewとの組み合わせ) 2. UIScrollViewDelegateに関する印象: ScrollViewで用意されているModifierやScrollViewReaderを併用して応用する事で、様々な目を惹くAnimation やUI表現を作る事が可能ではあるが、Viewの重なり・処理のトリガーを整理する必要がある。 https://www.youtube.com/watch?v=3k7PP6TE1h4 Xcode 16 & iOS 18 での新機能解説 📹 参考動画: SwiftUIで提供されるScrollViewはバージョン アップの度に利用可能な機能が増えている。 scrollViewDidScrollを利用した処理で馴染みはあるが、値を利用して応用する必要がある。
  3. どの様な所に対して難しさを感じるのかを考えてみる SwiftUIを利用した画面構築やUI実装で利用する機会はあるがなかなか難しい これまでの過去事例からいくつかピックアップして解説できればと思います。 1. 簡単な実装 vs 複雑な実装における難易度とギャップ: 簡単な実装であれば、さほど苦労はしない事も多いかもしれません。しかしながら、Animation表現等にも関連がある様な複雑な 実装を考える際では、難易度が一気に上がってしまう事は多いと思います。 2.

    座標基準をどこに設定するか?という問題: GeometryReaderでの表現を考える際に注意が必要なのは「座標基準をどこに設定するか?」という点にあると考えています。座 標位置の基準を誤ってしまうと、位置合わせやデバッグが困難になる場合があります。 3. 意外といい感じの事例が思い浮かばない?という問題: 私も最初GeometryReaderに関するサンプル実装を探していた際に、自分に合った難易度をものを探し当てる・コードの概要とポ イントを読み解く事に苦労しました。過去に取り組んだ打開策についても今回お伝えします。
  4. ScrollやGeometryReaderを利用した表現事例(1) // GrometryReaderを利用した背景用サムネイル画像Parallax表現部分 GeometryReader { geometry in getBackgroundViewBy( geometry: geometry,

    backgroundImageUrl: backgroundImageUrl ) } @ViewBuilder private func getBackgroundViewBy( geometry: GeometryProxy, backgroundImageUrl: URL? ) -> some View { if geometry.frame(in: .global).minY <= 0 { ZStack { KFImage(backgroundImageUrl).resizable() .aspectRatio(contentMode: .fill) Rectangle() .foregroundColor(cellThumbnailMaskColor) } .frame(width: geometry.size.width, height: geometry.size.height) } else { ZStack { KFImage(backgroundImageUrl).resizable() .aspectRatio(contentMode: .fill) Rectangle() .foregroundColor(cellThumbnailMaskColor) } .offset(y: -geometry.frame(in: .global).minY) .frame( width: geometry.size.width, height: geometry.size.height + geometry.frame(in: .global).minY ) } } // (1) ScrollViewにおいて一番上にある状態から更に下方向へスクロールした場合 // 👉 Header用のサムネイル画像が拡大される様な形の表現をするための処理 // (2) 上方向へスクロールした場合 // 👉 画像の比率を維持してピッタリと画面にはまる大きさを保持した状態でスク ロールに追従させるための処理 上端が固定されて サムネイルが拡大 proxy.sizeの値を適 用する事で実現する ※不合致時は通常Scroll
  5. ScrollやGeometryReaderを利用した表現事例(2) 一番下の要素表示時に 次のPage要素を表示 https://medium.engineering/how-to-do-pagination-in-swiftui-04511be7fbd1 参考資料: https://apoorv487.medium.com/pagination-in-swiftui-5a90ea952876 ScrollView { ForEach(archiveCellViewObjects) {

    viewObject in ArchiveCellView( viewObject: viewObject, targetKeyword: targetKeyword, targetCategory: targetCategory, tapIsStoredButtonAction: { isStored in  // 👉 Favoriteボタン(ハート型ボタン要素)タップ時処理 } ) .onAppear { // 👉 次ページに表示する要素取得を試みる viewModel.fetchNextPage() } } } ※この処理ではGeometryReaderを利用しなくとも実現可能 `fetchNextPage()`内でindex値等を利用し「一番下のデータが表示された」事を検知 する。そして条件合致時にはAPIから次ページ要素一覧を取得し末尾から追加する。
  6. スクロールに連動した日付選択タブの動きを作る(1) デバイス幅等の固有値を利用した位置計算時には回転時の考慮に注意する ① iPad (Portrait & Landscape): ② iPhone (Portrait

    & Landscape): calculateButtonWidth(deviceWidth: geometry.size.width ) GeometryReaderを利用してButton要素幅を計算する部分 回転に伴い値が変化する
  7. スクロールに連動した日付選択タブの動きを作る(2) Android側での実装をまずは調査しタブ部分がどの様な構造かを読み解いてみる SwiftUI側では類似要素は提供されていませんが、擬似的な背景の動きはAnimationで実現できそうでした。 1. TimetableTabRow: 2. TimetableTab: 内部ではTabRowを利用した実装 Android側 (Jetpack

    Compose) での実装ポイント 内部ではTabを利用した実装 基本的な実装はTabRow+Tabの組み合わせで実現する これを踏まえてiOS側でどう実現するか? 3. TimetableSheetContentScrollState: 🍎 iOS側の構造はButtonを利用していた形でした + 現在時点でのScroll変化量を保持しているState
  8. スクロールに連動した日付選択タブの動きを作る(3) Animationを伴う背景部分とButton配置部分を分離してGeometryReaderを利用 3つ横並びのButton要素に定義したModifier: .background(alignment: .center) { GeometryReader { geometry in

    Capsule() .fill(AssetColors.Primary.primary.swiftUIColor) .frame( width: calculateButtonWidth(deviceWidth: geometry.size.width), height: calculateButtonHeight() ) .offset(x: calculateDynamicTabHorizontalOffset(deviceWidth: geometry.size.width), y: 10) .animation(.easeInOut(duration: 0.16), value: selectedDay) } } private func calculateDynamicTabHorizontalOffset(deviceWidth: CGFloat) -> CGFloat { let buttonAreaWidth = calculateButtonWidth(deviceWidth: deviceWidth) // Get the index value corresponding to `selectedDay` and use it for calculation let indexBySelectedDay = getIndexBySelectedDay() return buttonAreaLeadingMargin + (betweenButtonMargin + buttonAreaWidth) * CGFloat(indexBySelectedDay) } private func calculateButtonWidth(deviceWidth: CGFloat) -> CGFloat { // Calculate button width considering related margins let excludeTotalMargin = calculateExcludeTotalMargin() return (deviceWidth - excludeTotalMargin) / CGFloat(buttonsCount) } この様に分離して考えています 14 15 16 Day1 Day2 Day3 Slide Animation 本 体 背 景 ① X軸方向のOffset値をGeometryReaderより算出 ② ボタン背景の幅をGeometryReaderより算出
  9. スクロールに連動した日付選択タブの動きを作る(4) 配置要素の間隔等も考慮した位置関係を算出する際は見通しが良い状態にする 特にMargin値・Button要素配置数・Index値等の計算に活用するものを整理する: private func calculateDynamicTabHorizontalOffset(deviceWidth: CGFloat) -> CGFloat {

    let buttonAreaWidth = calculateButtonWidth(deviceWidth: deviceWidth) // Get the index value corresponding to `selectedDay` and use it for calculation let indexBySelectedDay = getIndexBySelectedDay() return buttonAreaLeadingMargin + (betweenButtonMargin + buttonAreaWidth) * CGFloat(indexBySelectedDay) } private func getIndexBySelectedDay() -> Int { Int(selectedDay.ordinal) } private func calculateButtonWidth(deviceWidth: CGFloat) -> CGFloat { // Calculate button width considering related margins let excludeTotalMargin = calculateExcludeTotalMargin() return (deviceWidth - excludeTotalMargin) / CGFloat(buttonsCount) } private func calculateExcludeTotalMargin() -> CGFloat { let totalBetweenButtonMargin = betweenButtonMargin * CGFloat(buttonsCount - 1) return buttonAreaLeadingMargin + buttonTrailingMargin + totalBetweenButtonMargin } // Define margin values to calculate horizontal position // (for capsule rectangle) private let buttonAreaLeadingMargin = 16.0 private let buttonTrailingMargin = 16.0 private let betweenButtonMargin = 8.0 // Define all button count to calculate horizontal position // (for capsule rectangle) private var buttonsCount: Int { Int(DroidKaigi2023Day.values().size) }
  10. スクロールに連動した日付選択タブの動きを作る(7) Scroll変化量を取得する必要があるのでScrollViewを拡張するアプローチ GeometryReader & PreferenceKey の組み合わせ: public struct ScrollViewWithVerticalOffset<Content: View>:

    View { let onOffsetChange: (CGFloat) -> Void let content: () -> Content public init( onOffsetChange: @escaping (CGFloat) -> Void, @ViewBuilder content: @escaping () -> Content ) { self.onOffsetChange = onOffsetChange self.content = content } // ポイント: ScrollView(.vertical) 内部でGeometryReaderを利用する // 👉 Scroll変化量を取得できる様な形にしている } // ① body部分 public var body: some View { ScrollView(.vertical) { offsetReader content() .padding(.top, 0) } .coordinateSpace(name: "frameLayer") .onPreferenceChange(OffsetPreferenceKey.self, perform: onOffsetChange) } // ② Offset値読み取り処理部分 private var offsetReader: some View { GeometryReader { proxy in Color.clear .preference( key: OffsetPreferenceKey.self, value: proxy.frame(in: .named("frameLayer")).minY )   } .frame(height: 0) } } private struct OffsetPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = .zero static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {} }
  11. スクロールに連動した日付選択タブの動きを作る(8) 画面全体構造における基本方針は「ZStack & ScrollView & LazyVStack」の構成 全体を構成するTimetableView.swiftにおけるScrollViewのOffset値と関連するAnimation処理の抜粋: ZStack(alignment: .topLeading) {

    ZStack(alignment: .top) { // ※ …(画面上部に固定表示される要素を記述)… } ScrollViewWithVerticalOffset( onOffsetChange: { offset in shouldCollapse = offset < verticalOffsetThreshold }, content: { Spacer().frame(height: 130) LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) { Section( header: TimetableDayHeader( selectedDay: viewModel.state.selectedDay, shouldCollapse: shouldCollapse, onSelect: { [weak viewModel] in viewModel?.selectDay(day: $0) } ) .frame(height: shouldCollapse ? 53 : 82) .animation(.easeInOut(duration: 0.08), value: shouldCollapse) ) { // ※ …(一覧表示要素を記述)… } } // Determines whether or not to collapse. private let verticalOffsetThreshold = -142.0 // When offset value is exceed the threshold, TimetableDayHeader collapse with animation. @State private var shouldCollapse = false TimetableHeader.swiftの動き 15 16 Day2 Day3 14 Day1 Day2 Day3 Day1 Collapse or Expand ① 取得したOffset値がしきい値を超えたかどうかを見る ② shouldCollapse値の変化でAnimation発火
  12. スクロールに連動した日付選択タブの動きを作る(9) Header要素内部の表示についてもOffset値を基準として表示内容を変更する .background(alignment: .center) { GeometryReader { geometry in Capsule()

    .fill(AssetColors.Primary.primary.swiftUIColor) .frame( width: calculateButtonWidth(deviceWidth: geometry.size.width), height: calculateButtonHeight() ) .offset(x: calculateDynamicTabHorizontalOffset(deviceWidth: geometry.size.width), y: 10) .animation(.easeInOut(duration: 0.16), value: selectedDay) } } TimetableDayHeader( selectedDay: viewModel.state.selectedDay, shouldCollapse: shouldCollapse, onSelect: { [weak viewModel] in viewModel?.selectDay(day: $0) } ) Button { onSelect(day) } label: { VStack(spacing: 0) { Text(day.name) .textStyle(TypographyTokens.labelSmall) if !shouldCollapse { Text("\(day.dayOfMonth)") .textStyle(TypographyTokens.headlineSmall) .frame(height: 32) } } .padding(shouldCollapse ? 6 : 4) .frame(maxWidth: .infinity) .foregroundStyle( // ※selectedDayに応じて背景色を変化 ) } Button背景Modifier Button要素本体 変数: shouldCollapseを元に高さを調節
  13. お気に入り追加時のハートマークAnimation対応(1) Androidで実現する動きが綺麗でしたので、SwiftUIでもできるか試してみた 既存のView構造でどう構築するか?を考える: 直線的な動きならば強引にできるかも: 1. ScrollViewを基準とした位置取得 2. Animation終端位置をGeometryReaderで計算する 👉 画面を基準とした位置はGeometryReaderから算出

    Animation概要 👉 DragGestureの処理で代用できないか?を考えてみる ① Button要素では現在の位置が取得できなかった 課題となった部分 ❤ ❤ 理想の動き 現実の動き ❤ ❤ 弧を描く 直線的 👉 お気に入りボタンで追加処理時に位置情報を渡す ② お気に入りに追加した時の考慮ポイント 👉 おおもとの画面をZStackにしてAnimation用View要素と分離する
  14. お気に入り追加時のハートマークAnimation対応(2) タイムテーブル表示要素からタップされた位置を取得するための処理抜粋 // [NOTE] In order to calculate the value

    from GeometryReader, it is supported by assigning DragGesture to the Image element instead of Button. HStack { GeometryReader { geometry in // MEMO: Since the coordinate values ​ ​ are based on the inside of the View, ".local" is specified. let localGeometry = geometry.frame(in: .local) Image(isFavorite ? .icFavoriteFill : .icFavoriteOutline) .resizable() .renderingMode(.template) .foregroundColor( isFavorite ? AssetColors.Primary.primaryFixed.swiftUIColor : AssetColors.Surface.onSurfaceVariant.swiftUIColor ) .frame(width: 24, height: 24) .gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global).onEnded { dragGesture in // MEMO: The offset value in the Y-axis direction is subtracted for adjustment (decided by device orientation). let adjustedLocationPoint = CGPoint(x: dragGesture.location.x, y: dragGesture.location.y - calculateTopMarginByDevideOrietation()) onTapFavorite(timetableItem, adjustedLocationPoint) }) // MEMO: To adjust horizontal position, I'm subtracting half the size of Image (-12). .position(x: localGeometry.maxX - 12, y: localGeometry.midY) } } let onTapFavorite: (TimetableItem, CGPoint?) -> Void タップ時には該当するTimetableItemと一緒 に.gestureで取得できたGeometryReaderの 値を一覧表示するView要素へ送る。 回転の考慮も必要 👉 基準はこのView内部 👉 .positionではView内部を基準としたX軸&Y軸方向の位置を指定する 👉 最終的に送られるのは画面全体を基準とした位置の値 - DragGestureを利用してボタンタップの様に見せている - Drag終了時位置をClosureで送信(coordinateSpace: .global)
  15. ZStack { ScrollView { LazyVStack(spacing: 0) { ForEach(store.timetableItems, id: \.self)

    { item in TimeGroupMiniList(contents: item, onItemTap: { item in store.send(.view(.timetableItemTapped(item))) }, onFavoriteTap: { timetableItemWithFavorite, adjustedLocationPoint in store.send(.view(.favoriteTapped(timetableItemWithFavorite))) // MEMO: When "isFavorited" flag is false, this view executes animation. if timetableItemWithFavorite.isFavorited == false { toggleFavorite(timetableItem: timetableItemWithFavorite.timetableItem, adjustedLocationPoint: adjustedLocationPoint) } }) } }.scrollContentBackground(.hidden) .onAppear { store.send(.view(.onAppear)) }.background(AssetColors.Surface.surface.swiftUIColor) bottomTabBarPadding } // MEMO: Stack the Image elements that will be animated using ZStack. makeHeartAnimationView() } お気に入り追加時のハートマークAnimation対応(3) タイムテーブル一覧画面の構造は一覧表示と動きを作るための要素を分割する 👉 タイムテーブル一覧表示はScrollView + LazyVStack TimetableItemと一緒に.gestureで取得 できたGeometryReaderの値 @ViewBuilder private func makeHeartAnimationView() -> some View { GeometryReader { geometry in if targetTimetableItemId != nil { Image(systemName: "heart.fill") .foregroundColor( AssetColors.Primary.primaryFixed.swiftUIColor ) .frame(width: 24, height: 24) .position(animationPosition(geometry: geometry)) .opacity(1 - animationProgress) .zIndex(99) } } } ハートマークが画面上を動く様に見せる 👉 ハートマークImage要素を単体で重ねて表示する 👉 @Stateの値変化でAnimationを実行 Animationを実行するために@Stateを更新
  16. お気に入り追加時のハートマークAnimation対応(4) @ViewBuilder private func makeHeartAnimationView() -> some View { GeometryReader

    { geometry in if targetTimetableItemId != nil { Image(systemName: "heart.fill") .foregroundColor( AssetColors.Primary.primaryFixed.swiftUIColor ) .frame(width: 24, height: 24) .position(animationPosition(geometry: geometry)) .opacity(1 - animationProgress) .zIndex(99) } } } // MEMO: A variable that stores the value of Animation variation. (Only 0 or 1) @State private var animationProgress: CGFloat = 0 // MEMO: Select target targetTimetableItemId & targetLocationPoint (for Animation). @State private var targetTimetableItemId: TimetableItemId? @State private var targetLocationPoint: CGPoint? private func animationPosition(geometry: GeometryProxy) -> CGPoint { // MEMO: Get the value calculated from both the default and .global GeometryReader. let globalGeometrySize = geometry.frame(in: .global).size let defaultGeometrySize = geometry.size // MEMO: Calculate the offset value in the Y-axis direction using GeometryReader. let startPositionY = targetLocationPoint?.y ?? 0 let endPositionY = defaultGeometrySize.height - 25 let targetY = startPositionY + (endPositionY - startPositionY) * animationProgress // MEMO: Calculate the offset value in the X-axis direction using GeometryReader. let adjustedPositionX = animationProgress * (globalGeometrySize.width / 2 - globalGeometrySize.width + 50) let targetX = defaultGeometrySize.width - 50 + adjustedPositionX return CGPoint(x: targetX, y: targetY) } 👉 toggleFavoriteを実行すると@Stateが更新される - animationProgressの値はアルファ値と計算で利用する - タイムテーブルID & タップ位置もnullableの変数で保持する - Animation終了位置算出や要素表示可否の決定をする 👉 GeometryReaderと@Stateで位置を算出する ②位置&アルファ更新 ①表示可否 private func toggleFavorite(timetableItem: TimetableItem, adjustedLocationPoint: CGPoint?) { targetLocationPoint = adjustedLocationPoint targetTimetableItemId = timetableItem.id // MEMO: Execute animation. if targetTimetableItemId != nil { withAnimation(.easeOut(duration: 1)) { animationProgress = 1 } Task { try await Task.sleep(nanoseconds: 1_000_000_000) targetTimetableItemId = nil targetLocationPoint = nil animationProgress = 0 } } }
  17. タブ表示要素のView構造とScroll連動処理(1) 考えやすくするためにUIKit利用時と同様に大きく2つの要素に分けてみる Body要素内はタブ要素とコンテンツ要素の様な粒度に分割してみる: ページング処理に 合わせて下線が移動 タブ要素を押下時に 該当コンテンツを表示 // MARK: -

    Body var body: some View { NavigationStack { VStack(spacing: 0.0) { // 1. Slider式のTab要素を並べたView要素 PremiumPosterTabView() // 2. Slider式のContents要素を並べたView要素 PremiumPosterContentsView() } // Navigation表示に関する設定 .navigationTitle(“Premium Poster") .navigationBarTitleDisplayMode(.inline) } } UIKitの場合はDelegateを応用してView間の処理を接続したが、SwiftUI利用時はどうするか? 別々に定義したView要素間の 処理を最終的には接続する。 👉 `@State`での状態変化を応用 View要素の位置変化に特に注目
  18. タブ表示要素のView構造とScroll連動処理(2) `@State`で定義した変数が「何が変化した時に更新されるか?」に注目する それぞれのView要素内で発生した状態変化を格納する: ページング処理に 合わせて下線が移動 タブ要素を押下時に 該当コンテンツを表示 // MARK: -

    `@State` Property // 配置対象のTab要素全てを格納する変数 @State private var tabs: [PosterLineupModel] // 現在選択されているTab要素としての変数 @State private var activeTab: PosterLineupModel.Tab // Tab要素をスクロールした時の状態を格納する変数 @State private var tabViewScrollState: PosterLineupModel.Tab? // メインContents要素をスクロールした時の状態を格納する変数 @State private var mainViewScrollState: PosterLineupModel.Tab? // Drag操作をしている最中の変化量を一時的に格納する変数 @State private var progress: CGFloat // 任意のTab要素タップ時からAnimation動作中に表示する連打防止用矩形エリア表示フラグ @State private var showRectangleToPreventRepeatedHits: Bool 定義した変数がいかなるタイ ミングで更新され、どの処理 のために利用されるか?を整 理しながら進めていく。 👉 わかりやすい変数名が良い
  19. タブ表示要素のView構造とScroll連動処理(3) 今回紹介するサンプルでは対象要素のCGRect型を返しその値をView処理で利用 GeometryReaderからOffset値を返す様に調整する: extension View { // MARK: - Function

    @ViewBuilder func getRectangleView(completion: @escaping (CGRect) -> ()) -> some View { // .overlay表示用Modifier内の処理でOffset値を取得できる形にする self.overlay { // GeometryReader内部にはColorを定義してScrollView内に配置する要素には極力影響を及ぼさない様にする GeometryReader { proxy in let rectangle = proxy.frame(in: .scrollView(axis: .horizontal)) // 👉 OffsetPreferenceKey定義とGeometryProxyから取得できる値を紐づける事でこの値変化を監視対象に設定する Color.clear .preference(key: OffsetPreferenceKey.self, value: rectangle) .onPreferenceChange(OffsetPreferenceKey.self, perform: completion) } } } } // MEMO: 配置したTab要素に対して座標位置を取得するためのExtension定義 // GeometryReaderを利用して、親Viewの座標情報等が利用できる点を活用する // 参考: https://blog.personal-factory.com/2019/12/08/how-to-know-coorginate-space-by-geometryreader/ struct OffsetPreferenceKey: PreferenceKey { static var defaultValue: CGRect = .zero static func reduce(value: inout CGRect, nextValue: () -> CGRect) { value = nextValue() } } 透明な背景要素をOffset値の監視対象とする点がポイント .getRectangleView { rect in // … (この値を活用した処理を実行する) … } Tab要素サイズや移動量の割合算出で利用 (用途に合わせてGeometryReaderの基準を変える)
  20. タブ表示要素のView構造とScroll連動処理(4) 基本構造はScrollViewから取得した変化量を表示更新に必要な`@State`へ適用 @ViewBuilder private func PremiumPosterContentsView() -> some View {

    // GeometryReaderを利用してContents表示要素の移動変化量を取得する GeometryReader { proxy in let targetSize = proxy.size // GeometryReaderから取得した値とScrollViewを連動させる方針を取る ScrollView(.horizontal, showsIndicators: false) { // 横一列にタブ要素分だけ対応するコンテンツ要素を並べる LazyHStack(spacing: 0.0) { ForEach(tabs) { tab in // TODO: コンテンツ要素用のView要素を作成する } } .scrollTargetLayout() // 独自に定義した「.getRectangleView」を利用してX軸方向のOffset値を取得する .getRectangleView { rect in // 変化量の割合を格納する変数「progress」へDrag操作最中の変化量を格納する progress = -rect.minX / targetSize.width } } // … ScrollView用のModifier定義 … } } Tab要素の文字列下部に配置した「動 く下線表示」のX軸方向のOffset値に なる点がポイント。 .scrollPosition(id: $mainViewScrollState) .scrollTargetBehavior(.paging) // コンテンツ表示要素におけるX軸方向のOffset値を格納する変数 // 「mainViewScrollState」の変化時に実行される処理 .onChange(of: mainViewScrollState) { oldValue, newValue in if let newValue { // .snappyで弱いバネ運動の様な感じを演出する withAnimation(.snappy) { activeTab = newValue tabViewScrollState = newValue } } } 👉 Tab要素のスクロール位置 & 現在選択中Tab要素を更新 表示コンテンツが メインとなる操作 変数: progresの値は タブ要素関連の位置調 整のために利用する。
  21. タブ表示要素のView構造とScroll連動処理(5) iOS17から追加されたScroll関連Modifierとの連携処理に注目する 実装ポイントをまとめる: ① GeometryReaderから取得した値とScrollViewを連動させる方針を取る 👉 ScrollView & LazyHStackの組み合わせなので、X軸方向のOffset値に注目する ②

    .scrollTargetLayout() & .scrollPosition(id: $mainViewScrollState) に関する解説 👉 scrollTargetLayout(): ScrollView内で特定の位置までスクロールするために必要なModifier 👉 scrollPosition(id: $mainViewScrollState): コンテンツ表示要素におけるX軸方向のOffset値を格納する変数「mainViewScrollState」の位置まで移動 するために必要なModifier 👉 scrollTargetBehavior(.paging): 配置したScrollViewどのように機能するかを決定するためのModifier(今回は.pagingを指定してページ切替の形) https://developer.apple.com/documentation/swiftui/view/scrolltargetlayout(isenabled:) https://developer.apple.com/documentation/swiftui/view/scrollposition(id:anchor:) https://developer.apple.com/documentation/swiftui/scrolltargetbehavior
  22. タブ表示要素のView構造とScroll連動処理(6) 様々な要素がかなり複雑に絡み合う構造をとるView要素なので整理がとても大事 @ViewBuilder private func PremiumPosterTabView() -> some View {

    // MEMO: ZStackを利用して、Tab全体に必要な要素を配置する。 ZStack(alignment: .leading) { // ① Tab要素配置用のScrollView // ② Tab表示エリアに合わせる形で連打防止用にRectangleを重ねる } .scrollPosition(id: $tabViewScrollState, anchor: .center) // Tab要素を並べたScrollViewの上に更に要素を重ねる形を取る .overlay(alignment: .bottom) { // ③ コンテンツ部分のスクロール処理と連動する } .safeAreaPadding(.horizontal, 16.0) } // 👉 .clearを指定すると連続タップ時にTab要素が意図しない位置で停止する // 👉 任意の色を定めてopacityを0未満の小さな値にして対処 if showRectangleToPreventRepeatedHits { Rectangle().fill(.red.opacity(0.001)) .frame(height: 36.0).padding(.horizontal, -16.0) } タブ表示部分を連打し た際においても正しく 動作する様に②の様な 考慮を加える。 ForEachで横1列のButton要素 に並べて表示する。 // 0.00〜0.35秒間は連打防止用の矩形要素を表示した状態にする Task { showRectangleToPreventRepeatedHits = true try await Task.sleep(for: .milliseconds(350)) showRectangleToPreventRepeatedHits = false } // .snappyで弱いバネ運動の様な感じを演出する withAnimation(.snappy) { activeTab = tab.id // 👉 Tab要素のスクロール位置 tabViewScrollState = tab.id // 👉 現在選択されているTab要素 mainViewScrollState = tab.id // 👉 現在選択されているContents要素を更新する } ForEach($tabs) { $tab in … } .getRectangleView { rect in … } .scrollTargetLayout() 動く下線要素をこちらに配置する。
  23. タブ表示要素のView構造とScroll連動処理(7) 線形補間の計算式の考え方を応用する事でより自然な表現にするための工夫 この処理があると何が嬉しいのか?: // Tab要素のindex値をArrayに変換する let inputRange = tabs.indices.compactMap {

    CGFloat($0) } // Tab要素の文字列幅をArrayに変換する let ouputRange = tabs.compactMap { $0.size.width } // Tab要素を並べた時のX軸方向のOffset値の一覧をArrayに変換する let outputPositionRange = tabs.compactMap { $0.minX } // 動く下線要素の幅が変化して、次のタブ要素へ進む(前のタブ要素へ戻る)際の幅を算出する let indicatorWidth = progress.calculateInterpolate( inputInterpolateRange: inputRange, outputInterpolateRange: ouputRange ) // 動く下線要素の幅が変化して、次のタブ要素へ進む(前のタブ要素へ戻る)際のX軸方向のOffset値を算出する let indicatorPosition = progress.calculateInterpolate( inputInterpolateRange: inputRange, outputInterpolateRange: outputPositionRange ) 線形補間の計算式と近似誤差: https://manabitimes.jp/math/1422 タブ要素に表示される名前はない様に応じて文字数が変化するの で、切り替わるタイミングで下線の長さを自然に変化させる。 変化量に応じて表示文字列&下線長さが変化する形。 変数: progresの値が計算のベースになる点がポイント
  24. タブ表示要素のView構造とScroll連動処理(8) // 座標点の配列を元にした // 線形補間の計算を利用した座標位置 func calculateInterpolate(   inputInterpolateRange: [CGFloat], outputInterpolateRange:

    [CGFloat] ) -> CGFloat let positionX = self let length = inputInterpolateRange.count - 1 // 最初に与えられた値が最初の入力値より小さい場合は、最初の出力値を返す if positionX <= inputInterpolateRange[0] { return outputInterpolateRange[0] } // 与えられた点の間を近似する処理を実行する // 👉 この値を利用する事でDrag移動時に伴って移動するオブジェクトに対して滑らかな動きを付与する事が可能 for index in 1...length { // 2点間(x1, y1) & (x2, y2)の座標を算出する let x1 = inputInterpolateRange[index - 1] let x2 = inputInterpolateRange[index] let y1 = outputInterpolateRange[index - 1] let y2 = outputInterpolateRange[index] // 算出した座標値を元に線形補間の計算を実行して変化量を算出する // 👉 線形補間の計算式: y1 + ((y2 - y1) / (x2 - x1)) * (positionX - x1) if positionX <= inputInterpolateRange[index] { let positionY = y1 + ((y2 - y1) / (x2 - x1)) * (positionX - x1) return positionY } } // 線形補間の計算で算出できなかった場合は、出力値の最後の値を返す様にする return outputInterpolateRange[length] // 動く下線要素の幅が変化して、次のタブ要素へ進む(前の タブ要素へ戻る)際の幅を算出する let indicatorWidth = progress.calculateInterpolate( inputInterpolateRange: inputRange, outputInterpolateRange: ouputRange ) // 動く下線要素の幅が変化して、次のタブ要素へ進む(前の タブ要素へ戻る)際のX軸方向のOffset値を算出する let indicatorPosition = progress.calculateInterpolate( inputInterpolateRange: inputRange, outputInterpolateRange: outputPositionRange ) 補間の公式の考え方をUI実装に応用する 下線部分の表現を綺麗にするための工夫となる点。
  25. サムネイル画像ギャラリーの様な形を考えてみる(1) 画像要素が横一列に並んで自動で左方向へ進んでいく&Drag処理ができる仕様 指でのScroll & Timer処理の2つを許可 αϜωΠϧը૾ΪϟϥϦʔ GeometryReaderで実装できる?最新のModifierを活用する? ᶃ ᶄ ᶅ

    ᶆ ᶉ ᶊ ᶃ ᶄ αϜωΠϧը૾ΪϟϥϦʔ Drag処理時でも自然な繋ぎ目になる SwiftUIで実装する際に起こり得る課題を整理する ポイントになるのはしきい値を超過した際の位置変更処理をどうするかの部分 ※イメージ図解を紐解くとこの様な形になる 予め表示想定の3倍の 表示要素を確保する ՄࢹྖҬ ՄࢹྖҬ ՄࢹྖҬ 後はこの処理を 繰り返す形 Carousel要素全体の長さの2倍 を超過した場合は位置調整
  26. サムネイル画像ギャラリーの様な形を考えてみる(2) 自動での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処理
  27. サムネイル画像ギャラリーの様な形を考えてみる(3) 計算処理におけるポイントになるのは「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に配置するのは表示する要素の個数 別途変数に保持しておく
  28. サムネイル画像ギャラリーの様な形を考えてみる(4) 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を動かす。
  29. サムネイル画像ギャラリーの様な形を考えてみる(5) 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値に反映
  30. 補足1. ScrollViewReaderを併用した表現事例 $VSSZ 1J[[B 4BOEXJUDI 🍛 ΧϨʔಛू … List要素等でSection毎に一覧表示 $VSSZ

    1J[[B 4BOEXJUDI 🍕  ϐοπΝಛू SectionHeader要素 次のSectionへ到達 スクロール実行 🔖 ScrollViewReaderを活用した処理と合わせる Contents要素のスクロール時にTab要素を移動 & Tab要素押下時は特定要素Section要素まで移動 参考資料: https://www.youtube.com/watch?v=XUeophZ1iTo