0
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-01-23

概要

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

実行環境
  • Xcode 14.2
  • Swift5.7.2
  • iOS 16.2
  • macOS Ventura バージョン 13.1

完成イメージ

完成イメージ.gif

実装

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

表の要素であるcolumnsrowsが少なくなると表示がずれてしまうので、それを補正する関数zeroIn()をつくって対応しました。

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

0
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?