Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

こんなUIってSwiftUIでこう作るのか!を解剖してみた

 こんなUIってSwiftUIでこう作るのか!を解剖してみた

2024/07/14開催: 神山.swift & 2024/07/17開催: YUMEMI.grow Mobile #15の登壇資料になります!

最近の取り組みとして、YouTube等で紹介されているUI実装サンプルを参考にしながら、実装における要点を自分でまとめる活動をしています。その中でUIKit利用時でも複雑な実装を、SwiftUIを利用する方針で実現した事例におけるポイントを解説したものになります。

解説サンプルGist:
https://gist.github.com/fumiyasac/7429e33a244c980de494cb8c5a616f48

最近の事例:
https://x.com/fumiyasac/status/1808177826237239418
https://x.com/fumiyasac/status/1810009745065132152

Fumiya Sakai

July 14, 2024
Tweet

More Decks by Fumiya Sakai

Other Decks in Technology

Transcript

  1. (第1版) 2024/07/14: 神山.swift @ 徳島県神山町 (第2版) 2024/07/17: YUMEMI.grow Mobile #15

    @ オンライン こんなUIってSwiftUIでこう作るのか! を解剖してみた Fumiya Sakai
  2. 自己紹介 ・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
  3. 対象となるUI実装に関連する基本事項を確認する UICollectionView(UIScrollView)とUIPageViewControllerの組み合わせ UIKitで実現するためのポイント(基本部分): 具体的なUI表現イメージ例: 1. コンテンツ表示部分(UIPageViewControllerで作成された一 覧表示部分)に関する処理概要 2つ全く異なる振る舞いをする要素を1つの画面内で表現 該当Category別にデータ一覧を整理して表示する際には有効 UIPageViewControllerDelegateの

    didFinishAnimating 処理時 にIndex値に応じてタブ位置を更新する。 2. タブ型表示部分(UICollectionView or UIScrollViewを利用 して並べられた要素一覧部分)に関する処理概要 func scrollViewDidScroll(_ scrollView: UIScrollView) ① タブ型表示要素をタップすると、該当コンテンツ要素を表示。 ② コンテンツをスワイプ移動すると、タブ型表示要素が連動。
  4. (余談)このUI実装に無限循環スクロールをする場合 前述した基本事項に加えて更に無限循環をするための配慮が必要になる UICollectionView関連の 位置&Index調整処理 ポイントを整理するとこの様になります: 表示コンテンツ総数や表示タブ要素Index値を合わせるのが難しい 1. コンテンツ表示部分(UIPageViewControllerで作成された一覧表示部分)で追加考慮すべき事項 UIPageViewControllerDataSource内の処理でコンテンツ部分も 同様に無限循環する挙動を構築

    する。 2. タブ型表示部分(UICollectionViewを利用して並べられた要素一覧部分)で追加考慮すべき事項 UICollectionViewFlowLayoutを継承したクラスを適用して Cell要素が中央に停止する 形へ変更する 実際のタブ配置個数は無限循環スクロールに対応するために 実際の個数より多めに配置 しておく UIScrollViewDelegateを活用して無限循環タブ表現部分の挙動を構築する func scrollViewDidScroll(_ scrollView: UIScrollView) X軸方向のOffset値の計算を利用して、無限循環スクロールの挙動を実現する func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) スクロールが停止した際に見えているセルのインデックス値を格納して、真ん中にあるものを取得する ( .visibleCells を利用して表示されているCell要素を全て取得する)
  5. 今回サンプルを試すにあたり参考にしたYouTube動画 動画でライブコーディングをしているサンプル実装内容のインプットを実践する サンプルを紐解く際のポイント: 1. コードを読みながら必要処理をまずは自分の言葉で説明する https://www.patreon.com/kavsoft/posts ※有料会員コンテンツもあるみたいです(僕もまだ未登録です)。 参考: SwiftUI Scrollable

    Tab Bar - iOS 17 https://www.youtube.com/watch?v=sCK0W39nVEk UIKitで実現したタブ型スクロールを伴う一覧表示UI実装サンプル ① GeometryReaderとScrollViewを活用する X軸方向のOffset値を取得できる様にカスタマイズをする必要がある (GeometryReaderを応用する事で取得可能) 2. 変数名や意図しない重複をリファクタリングして整理する 3. 実践に近しいデータやコンテンツを準備して組み合わせる SwiftUIだけで実現する際に大事な部分はどこか? ② iOS17以降で追加されたScrollView関連のModifierを応用する 他にも@StateやDragGestureの使い方等に関する知識も必要になってくる点
  6. 類似したUI実装においてSwiftUIで実現する際の方針(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要素の位置変化に特に注目
  7. 類似したUI実装においてSwiftUIで実現する際の方針(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 定義した変数がいかなるタイ ミングで更新され、どの処理 のために利用されるか?を整 理しながら進めていく。 👉 わかりやすい変数名が良い
  8. GeometryReaderを利用して座標位置を取得するExtension 今回紹介するサンプルでは対象要素の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の基準を変える)
  9. コンテンツ表示要素のView構造とScroll連動処理 基本構造は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の値は タブ要素関連の位置調 整のために利用する。
  10. GeometryReaderから取得する値とScroll関連Modifier 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
  11. タブ表示要素のView構造とScroll連動処理(1) 様々な要素がかなり複雑に絡み合う構造をとる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() 動く下線要素をこちらに配置する。
  12. タブ表示要素のView構造とScroll連動処理(2) 線形補間の計算式の考え方を応用する事でより自然な表現にするための工夫 この処理があると何が嬉しいのか?: // 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の値が計算のベースになる点がポイント
  13. タブ表示要素のView構造とScroll連動処理(3) // 座標点の配列を元にした // 線形補間の計算を利用した座標位置 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実装に応用する 下線部分の表現を綺麗にするための工夫となる点。