概要
SwiftUIで、固定列・固定行のある、Exelのようなスプレッドシートをつくります。
スクロールでコンテンツが動き、それに合わせて固定列・固定行も追従します。
実行環境
- Xcode 14.2
- Swift5.7.2
- iOS 16.2
- macOS Ventura バージョン 13.1
完成イメージ
実装
「実際に表示されてるView」と「スクロールを検知するView」の2つが、ZStack
で重なっています。
「スクロールを検知するView」で取得したscrollOffset
の値によって、「実際に表示されてるView」の各要素が動きます。
表の要素であるcolumns
やrows
が少なくなると表示がずれてしまうので、それを補正する関数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