【SwiftUI】固定列・固定行のある表をつくる

#### 25/03/01 追記
↓ライブラリ化しました。
[SyncedScrollView](https://github.com/yu3san3/SyncedScrollView)

# 概要
SwiftUIで、固定列・固定行のある、Exelのようなスプレッドシートをつくります。
スクロールでコンテンツが動き、それに合わせて固定列・固定行も追従します。

<details><summary>実行環境</summary>

- Xcode 14.2
- Swift5.7.2
- iOS 16.2
- macOS Ventura バージョン 13.1

</details>

## 完成イメージ
![完成イメージ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118302/85c4ef72-9af0-6cd4-7950-426eb6af7fc3.gif)

# 実装

「実際に表示されてるView」と「スクロールを検知するView」の2つが、`ZStack`で重なっています。
「スクロールを検知するView」で取得した`scrollOffset`の値によって、「実際に表示されてるView」の各要素が動きます。

表の要素である`columns`や`rows`が少なくなると表示がずれてしまうので、それを補正する関数`zeroIn()`をつくって対応しました。
```swift:ContentView.swift
import SwiftUI

let columns = ["a","b","c","d","e","f","g","h","i","j"]
let rows = ["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15"]

class Table {
    
    //表の属性を決める
    let columnWidth: CGFloat = 50
    let columnHeight: CGFloat = 50
    let rowWidth: CGFloat = 50
    let rowHeight: CGFloat = 50
    
    let columnCount: Int = columns.count
    let rowCount: Int = rows.count
    
    var contentSize: CGSize {
        .init(
            width: (columnWidth * CGFloat(columnCount)) + rowWidth,
            height: (rowHeight * CGFloat(rowCount)) + columnHeight
        )
    }
    
    //Viewの幅を渡すと余白の幅が返ってくる
    let marginWidth = { (viewWidth: CGFloat) -> CGFloat in
        let table = Table()
        let viewWidthExcludingTopLeftCell: CGFloat = viewWidth - table.rowWidth
        let columnWidthExcludingTopLeftCell: CGFloat = table.contentSize.width - table.rowWidth
        return viewWidthExcludingTopLeftCell - columnWidthExcludingTopLeftCell
    }
    
    //Viewの高さを渡すと余白の高さが返ってくる
    let marginHeight = { (viewHeight: CGFloat) -> CGFloat in
        let table = Table()
        let viewHeightExcludingTopLeftCell: CGFloat = viewHeight - table.columnHeight
        let rowHeightExcludingTopLeftCell: CGFloat = table.contentSize.height - table.columnHeight
        return viewHeightExcludingTopLeftCell - rowHeightExcludingTopLeftCell
    }
    
    //表の要素が少ない時の表示のズレを補正する関数
    func zeroIn(_ value: CGPoint, geometry: GeometryProxy) -> CGPoint {
        var result: CGPoint = .zero
        result.x = value.x - max((marginWidth(geometry.size.width))/2, 0)
        result.y = value.y - max((marginHeight(geometry.size.height))/2, 0)
        return result
    }
}

struct ContentView: View {
    
    @State private var scrollOffset: CGPoint = .zero
    
    var table = Table()
    
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                //実際に画面に表示されてるView
                HStack(spacing: 0) {
                    leftContentView()
                    rightContentView(geometry)
                }
                //スクロールを検知するView
                ObservableScrollView(
                    axis: [.vertical, .horizontal],
                    scrollOffset: $scrollOffset,
                    table: table,
                    geometry: geometry
                ) { proxy in
                    Color.clear
                        .frame(
                            width: table.contentSize.width,
                            height: table.contentSize.height
                        )
                }
            }
        }
    }

    func leftContentView() -> some View {
        VStack(spacing: 0) {
            //左上セル
            Color.clear
                .frame(
                    width: table.rowWidth,
                    height: table.columnHeight
                )
                .border(Color.yellow)
            //列
            ScrollView(.vertical, showsIndicators: false) {
                VStack(spacing: 0) {
                    ForEach(rows, id: \.self) { row in
                        Text(row)
                            .frame(
                                width: table.rowWidth,
                                height: table.rowHeight
                            )
                            .border(Color.blue)
                    }
                }
                .offset(y: scrollOffset.y)
            }
            .disabled(true)
        }
    }

    func rightContentView(_ geometry: GeometryProxy) -> some View {
        VStack(spacing: 0) {
            //行
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: 0) {
                    ForEach(columns, id: \.self) { column in
                        Text(column)
                            .frame(
                                width: table.columnWidth,
                                height: table.columnHeight
                            )
                            .border(Color.red)
                    }
                }
                .offset(x: scrollOffset.x)
            }
            .disabled(true)
            //コンテンツ
            ScrollView([.vertical, .horizontal], showsIndicators: false) {
                VStack(spacing: 0) {
                    ForEach(rows, id: \.self) { row in
                        HStack(spacing: 0) {
                            ForEach(columns, id: \.self) { column in
                                Text("\(column), \(row)")
                                    .frame(
                                        width: table.columnWidth,
                                        height: table.rowHeight
                                    )
                                    .border(Color.green)
                            }
                        }
                    }
                }
                .offset(
                    //max関数のところでコンテンツの位置を調整してる
                    x: scrollOffset.x - max(table.marginWidth(geometry.size.width)/2, 0),
                    y: scrollOffset.y - max(table.marginHeight(geometry.size.height)/2, 0)
                )
            }
        }
    }
}

//CGPointに+=演算子を定義
private extension CGPoint {
    static func + (lhs: Self, rhs: Self) -> Self {
        CGPoint(
            x: lhs.x + rhs.x,
            y: lhs.y + rhs.y
        )
    }

    static func += (lhs: inout Self, rhs: Self) {
        lhs = lhs + rhs
    }
}

//PreferenceKeyを作成
private struct ScrollViewOffsetPreferenceKey: PreferenceKey {
    static var defaultValue = CGPoint.zero
    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
        value += nextValue()
    }
}

//スクロールを検知するView
private struct ObservableScrollView<Content: View>: View {
    let axis: Axis.Set
    @Binding var scrollOffset: CGPoint
    
    var table: Table
    let geometry: GeometryProxy
    
    let content: (ScrollViewProxy) -> Content
    @Namespace var scrollSpace
    
    var body: some View {
        ScrollViewReader { proxy in
            ScrollView(axis) {
                content(proxy)
                    .background(
                        GeometryReader { geo in
                            Color.clear
                                //ターゲットに設定、座標を伝える
                                .preference(
                                    key: ScrollViewOffsetPreferenceKey.self,
                                    value: geo.frame(in: .named(scrollSpace)).origin
                                )
                        }
                    )
            }
        }
        .coordinateSpace(name: scrollSpace)
        //座標の変化を検知する
        .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
            //scrollOffsetに座標を伝えてViewに反映させる
            scrollOffset = table.zeroIn(value, geometry: geometry)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
```

## 今後の課題
表示する要素の数が増えると表示が重くなります。

あと、iPhoneで実行すると良いんですが、macで実行したときの表示が安定しません。
なんでだろう？

# おわりに
Qiita初投稿です。Swiftについても、まだまだひよっこです。


何か「これをもっとこうした方が良い」「この処理はこう書いた方がきれい」などありましたら、教えていただけるとありがたいです。

### 参考文献
以下を大いに参考にさせていただきました。
[A view like spreadsheet in SwiftUI](https://gist.github.com/natpenguin/e6722b9a0841ba1430a978634e9dfcc8)