Quantumleap
715 words
4 minutes
SwiftUIでCollectionViewを実装する

CollectionView#

CollectionView は UiKit では実装されていたものの、SwiftUI では消されてしまった悲しき存在の一つ。

ですが、SwiftUI2.0 でLazyHGridが実装されたことによりそれっぽく CollectionView をつくることができるようになりました。

実装してみる#

実装にあたりこちらの記事を参考にさせていただきました。

主な仕様#

  • iOS14 のみで動作
    • LazyHGridが iOS13 では実装されていないため
  • 全てのページは同じ横幅を持つ
    • 読書アプリのようなものを想定
    • サイズが違う場合は参考ページのようにresizable()を使えば良いと思います
  • 画面を回転させた場合でも常に中央に表示される
    • 参考ページのコードでは回転時にレイアウトが崩れてしまうのでそれを修正しました
// ScrollViewのExtension
extension ScrollView {
    func paging(geometry: GeometryProxy, index: Binding<Int>, offset: Binding<CGFloat>, orientation: Binding<UIInterfaceOrientation>) -> some View {
        return self
            .content.offset(x: offset.wrappedValue)
            .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
                guard let status = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation else { return }
                if !UIDevice.current.orientation.isFlat {
                    if (orientation.wrappedValue.isPortrait != status.isPortrait) || (orientation.wrappedValue.isLandscape != status.isLandscape) {
                        offset.wrappedValue = -(geometry.size.height + (UIApplication.shared.windows.first?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0)) * CGFloat(index.wrappedValue)
                        orientation.wrappedValue = status
                    }
                }
            }
            .gesture(DragGesture()
                        .onChanged({ value in
                            offset.wrappedValue = value.translation.width - geometry.size.width * CGFloat(index.wrappedValue)
                        })
                        .onEnded({ value in
                            let scrollThreshold = geometry.size.width / 2
                            if value.predictedEndTranslation.width < -scrollThreshold {
                                index.wrappedValue = min(index.wrappedValue + 1, 10)
                            } else if value.predictedEndTranslation.width > scrollThreshold {
                                index.wrappedValue = max(index.wrappedValue - 1, 0)
                            }
                            withAnimation {
                                offset.wrappedValue = -geometry.size.width * CGFloat(index.wrappedValue)
                            }
                        })
            )
    }
}

画面の回転に対応させるのがやたらとめんどくさかったです。要するに、Portrait->LandscapeまたはLandscape->Portrait時にオフセットを再計算すればよいのですが、これを実装するためには「以前の状態」を保持しておく必要があります。

paging()内でもできるかもしれないのですが、わからなかったので今回は割愛してバカ正直に@Stateで保存するようにしました。

これで 180 度回転を含むどんな回転をさせてもちゃんと画面の中央に表示されます。

import SwiftUI

struct ContentView: View {
    @State private var index: Int = 0
    @State private var offset: CGFloat = 0
    @State private var orientation: UIInterfaceOrientation = .portrait

    var body: some View {
        GeometryReader { geometry in
            ScrollView(.horizontal, showsIndicators: false) {
                LazyHGrid(rows: Array(repeating: .init(.fixed(geometry.size.height)), count: 1), alignment: .center, spacing: 0, pinnedViews: []) {
                    ForEach(Range(0 ... 10)) { index in
                        Text("\(index)")
                            .frame(width: geometry.size.width, height: geometry.size.height, alignment: .center)
                            .background(Color.red.opacity(0.3).edgesIgnoringSafeArea(.all))
                    }
                }
            }
            .paging(geometry: geometry, index: $index, offset: $offset, orientation: orientation)
        }
    }
}

::: tip 謎のオフセット 20 が入る現象

Notification が呼ばれた段階ではステータスバーの高さが無視されているのか、常に 20 だけgeometry.sizeがズレてしまう問題があった。

そのため、わざわざステータスバーの高さを取得してその分だけ余計に計算している。が、ステータスーバーを非表示にしていたらなんかズレそうな気もする。

:::

記事は以上。

SwiftUIでCollectionViewを実装する
https://fuwari.vercel.app/posts/2021/06/collectioinview/
Author
tkgling
Published at
2021-06-07