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

SwiftUIで座標位置を取り扱う場合の細かなポイントを探ろう

 SwiftUIで座標位置を取り扱う場合の細かなポイントを探ろう

2024年11月27日に開催された、「Mobile勉強会 ウォンテッドリー × チームラボ × Sansan #17 @ Wantedly様」での登壇資料になります。

複雑なUI実装を実現する場合に、GeometryReaderを利用を考える事は多いと思います。GeometryReaderを有効活用する事で、SwiftUIだけでは難しい表現が構築可能になる反面、基準となる座標に注意が必要な点や位置計算を利用した値をうまく活用する必要がある等、実装を組み立てる段階での難しさもあります。
本発表では、これまでのOSS活動(DroidKaigi公式アプリ)内の事例においてGeometryReaderを活用した表現例とサンプル実装からイメージを膨らませていくためのヒントについてご紹介できればと思います。

Fumiya Sakai

November 27, 2024
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. 今回はGeometryReaderに関するお話しをします SwiftUIを利用した画面構築ではお世話になる機会は多そうだけども… これまでの過去事例からいくつかピックアップして解説できればと思います。 1. 簡単な実装 vs 複雑な実装における難易度とギャップ: 簡単な実装であれば、さほど苦労はしない事も多いかもしれません。しかしながら、Animation表現等にも関連がある様な複雑な 実装を考える際では、難易度が一気に上がってしまう事は多いと思います。 2.

    座標基準をどこに設定するか?という問題: GeometryReaderでの表現を考える際に注意が必要なのは「座標基準をどこに設定するか?」という点にあると考えています。座 標位置の基準を誤ってしまうと、位置合わせやデバッグが困難になる場合があります。 3. 意外といい感じの事例が思い浮かばない?という問題: 私も最初GeometryReaderに関するサンプル実装を探していた際に、自分に合った難易度をものを探し当てる・コードの概要とポ イントを読み解く事に苦労しました。過去に取り組んだ打開策についても今回お伝えします。
  3. スクロールに連動した日付選択タブの動きを作る(1) デバイス幅等の固有値を利用した位置計算時には回転時の考慮に注意する ① iPad (Portrait & Landscape): ② iPhone (Portrait

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

    Compose) での実装ポイント 内部ではTabを利用した実装 基本的な実装はTabRow+Tabの組み合わせで実現する これを踏まえてiOS側でどう実現するか? 3. TimetableSheetContentScrollState: 🍎 iOS側の構造はButtonを利用していた形でした + 現在時点でのScroll変化量を保持しているState
  5. スクロールに連動した日付選択タブの動きを作る(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より算出
  6. スクロールに連動した日付選択タブの動きを作る(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) }
  7. スクロールに連動した日付選択タブの動きを作る(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) {} }
  8. スクロールに連動した日付選択タブの動きを作る(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発火
  9. スクロールに連動した日付選択タブの動きを作る(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を元に高さを調節
  10. お気に入り追加時のハートマークAnimation対応(1) Androidで実現する動きが綺麗でしたので、SwiftUIでもできるか試してみた 既存のView構造でどう構築するか?を考える: 直線的な動きならば強引にできるかも: 1. ScrollViewを基準とした位置取得 2. Animation終端位置をGeometryReaderで計算する 👉 画面を基準とした位置はGeometryReaderから算出

    Animation概要 👉 DragGestureの処理で代用できないか?を考えてみる ① Button要素では現在の位置が取得できなかった 課題となった部分 ❤ ❤ 理想の動き 現実の動き ❤ ❤ 弧を描く 直線的 👉 お気に入りボタンで追加処理時に位置情報を渡す ② お気に入りに追加した時の考慮ポイント 👉 おおもとの画面をZStackにしてAnimation用View要素と分離する
  11. お気に入り追加時のハートマーク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)
  12. 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を更新
  13. お気に入り追加時のハートマーク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 } } }
  14. 補足1. 上手に応用できると表現の幅が一気に広がる 🍎 位置に応じてTab要素が折り畳まれる表現 位置に応じて Tab状態が変化 ① Tab要素内の矩形移動はGeometryReaderで作成 🔖 ScrollViewで利用可能なModifierの性質を利用する

    LazyVStackのSection要素 ② 一覧表示用のScrollViewではOffset値を取得 Navigation位置まで到達したらTab状態を変更 🐤 Scroll変化量と連動した複雑なTab要素例: Contents要素のスクロール変化量と連動させる 🍎 ScrollView要素で作るStickyHeader表現 条件 上端が固定されて サムネイルが拡大 ① GeometryReaderから取得した座標値を利用する ② 一覧表示用のScrollViewではOffset値を取得 proxy.frame(in: .global).minY <= 0 全体View要素配置や`@State`の条件を工夫する 🔖 true: proxy.sizeの値を適用する事で実現する ※条件に合致しない場合は通常通りのスクロール処理 🐤 Header要素と戻るボタン表示等を連動する: 特定位置に到達したら要素表示する等の工夫が可能 🍎 Sectionに応じてTab位置が変化 $VSSZ 1J[[B 4BOEXJUDI 🍛 ΧϨʔಛू … List要素等でSection毎に一覧表示 $VSSZ 1J[[B 4BOEXJUDI 🍕  ϐοπΝಛू SectionHeader要素 次のSectionへ到達 スクロール実行 🔖 ScrollViewReaderを活用した処理と合わせる Contents要素のスクロール時にTab要素を移動 & Tab要素押下時は特定要素Section要素まで移動
  15. まとめ 座標位置計算の基準を押さえる&多くの事例に触れ合う事が大切 複雑なレイアウト構築時ではGeometryReaderの使い方がポイントになる事も多いと思います。 1. GeometryReaderから取得した値を活用する事で様々な表現に応用可能: 今回は過去のDroidKaigiで取り組んだUI実装を題材にしてGeometryReaderを利用した計算事例を紹介しました。SwiftUI要素だけ では実現が難しいUI実装や表現であっても、取得した値をうまく活用する事で実現可能となる場合も多くあります。 2. GeometryReaderの基準を押さえて利用する値を整理する: GeometryReaderから取得した値を活用する場合は「どの位置を始点とするか?」という点に注意する。特にScroll処理と連動し

    て別要素に影響がある様なUI実装の場合は注意が必要になります。(ScrollView/ScrollViewReaderとの組み合わせ) 3. サンプル実装に触れて重要なポイントとイメージをセットでまとめる: コードを読むだけではUIの振る舞いや伴って変化する要素に対するイメージが掴みにくい場合も多い点に難しさがあると考えて います。仕様は勿論ですが、実装に関連する点を具体的なイメージをコードと併せてまとめると良いかと思います。