ScrollViewに表示されている情報でTextの値を変えたい
やりたいのは次のような動きらしい。(2022/01/07の動作)

本来は天気予報の日付を使うとのこと。
履歴
2022年1月7日 初版
開発環境
Swift5.5以降
Xcode13
手法
内部のセルが、ScrollViewの左端に掛かったら、そのセルの情報で他のViewを置き換える
ScrollViewの左端と内部のセルの比較
基本的に親(ScrollView)→子(内部のセル)の流れでデータは流れるので、子(内部のセル)→親(ScrollView)にデータは流せない。
子→親に情報を渡すにはPreferenceKeyを使う。
セル
スクロールさせるセルの構造体を作る。
/// スクロールしているセル(雑)
struct ScrollCell: View {
let hoge: String
let fuga: String
let piyo: String
var body: some View {
VStack {
Text(hoge)
.padding()
Text(fuga)
.padding()
Text(piyo)
.padding()
}
}
}
上からhoge、fuga、piyoを表示するだけのかんたんなViewです。
シングルトンのアプリ用クラス
本来はRestAPIから天気情報を取得するけれども、今回はベタ書きで作成。
試したい方は日付を変更してください。
その他、日付のフォーマッターはアプリ起動中一つで良いのでシングルトンで作成する。
class ApplicationManager {
static let shared = ApplicationManager()
// 本当は天気予報の情報なんだけれども、サンプルなのでまぁいいかなって。
static let scrollItem = [
ScrollCell(hoge: "1", fuga: "ほげ1", piyo: "2022-01-07"),
ScrollCell(hoge: "2", fuga: "ほげ2", piyo: "2022-01-07"),
ScrollCell(hoge: "3", fuga: "ほげ3", piyo: "2022-01-07"),
ScrollCell(hoge: "4", fuga: "ほげ4", piyo: "2022-01-07"),
ScrollCell(hoge: "5", fuga: "ほげ5", piyo: "2022-01-07"),
ScrollCell(hoge: "6", fuga: "ほげ6", piyo: "2022-01-08"),
ScrollCell(hoge: "7", fuga: "ほげ7", piyo: "2022-01-08"),
ScrollCell(hoge: "8", fuga: "ほげ8", piyo: "2022-01-08"),
ScrollCell(hoge: "9", fuga: "ほげ9", piyo: "2022-01-08"),
ScrollCell(hoge: "10", fuga: "ほげ10", piyo: "2022-01-08"),
ScrollCell(hoge: "11", fuga: "ほげ11", piyo: "2022-01-09"),
ScrollCell(hoge: "12", fuga: "ほげ12", piyo: "2022-01-09"),
ScrollCell(hoge: "13", fuga: "ほげ13", piyo: "2022-01-09"),
ScrollCell(hoge: "14", fuga: "ほげ14", piyo: "2022-01-09")
]
// 天気予報の日付を一旦Date型に変換させるためのもの
let toDateFormatter: DateFormatter
// 「今日」とか日付を相対的な表記で表示するためのもの
let relativeDateFormatter: DateFormatter
private init() {
let toDateFormatter = DateFormatter()
toDateFormatter.locale = Locale(identifier: "ja_JP")
// APIの仕様上、絶対にこのフォーマットである
toDateFormatter.dateFormat = "yyyy-MM-dd"
self.toDateFormatter = toDateFormatter
let relativeDateFormatter = DateFormatter()
relativeDateFormatter.locale = Locale(identifier: "ja_JP")
relativeDateFormatter.dateStyle = .medium
relativeDateFormatter.timeStyle = .none
relativeDateFormatter.doesRelativeDateFormatting = true
self.relativeDateFormatter = relativeDateFormatter
}
}
PreferenceKey
今回numberは要らないのだけれども、最初に作っていた時の名残で残っているもの……。
PreferenceKeyで使える型はEquatableでないといけないのでタプルは使えない。
そのため、PreferenceKey用の構造体を作成している。
PreferenceKeyは、親に子の情報を伝えるためのものなので、子情報が複数ある可能性があるため配列になる。
どんどん子の情報が追加されていく。
今回は全部追加して良いので、特になんの指定もなくappendしている。
Equatableの実装は、今回のケースではしなくても大丈夫。
struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: [ScrollValue] = []
static func reduce(value: inout [ScrollValue], nextValue: () -> [ScrollValue]) {
value.append(contentsOf: nextValue())
}
}
// PreferenceKey用の構造体(Equatableでないといけない)
struct ScrollValue: Equatable {
let number: Int
let value: CGFloat
let date: String
}
View
bodyに全部書くと長くなるので分けた。
スクロール内部のセルについては次の通り。
GeometryReaderを使って、セルの左端の位置をPreferenceKeyに追加している。
LazyHStackにしなかったのは、最高で2日後の情報なのでさほどメモリを食わないから。
LazyHStackでは「Bound preference ScrollOffsetPreferenceKey tried to update multiple times per frame.」とデバッグメッセージが出る。
// スクロールの内部のセル(配列分のView)
var content: some View {
HStack {
ForEach(ApplicationManager.scrollItem.indices) { index in
// スクロール内部のGeometryReader
GeometryReader { insideProxy in
ApplicationManager.scrollItem[index]
.preference(key: ScrollOffsetPreferenceKey.self,
value: [ScrollValue(number: index,
value: insideProxy.frame(in: .global).minX,
date: ApplicationManager.scrollItem[index].piyo)]
)
}
.frame(width: 150, height: 180)
Divider()
}
}
}
次に、スクロールの処理。
こちらもGeometryReaderを使っている。
ScrollViewがScrollCell(の配列)に対して親になる。
つまり、ここでPreferenceKeyを使ってサブViewの情報を取得し、GeometryReaderを使うことでどのセルが一番ScrollViewの左端に近いかを計算している。
一番左端のセルの日付を相対的な値(「今日」「明日」「明後日」)にして、message(「今日」などと表示しているTextの部分)と値が変わっているのならば、更新をする。
// スクロール処理
var scroll: some View {
GeometryReader { outsideProxy in
ScrollView(.horizontal) {
content
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
// スクロールの左端
let side = outsideProxy.frame(in: .global).minX
// sideよりも左にいっているセルは対象外。
let temp = value.filter { side < $0.value }
// 左端に一番近いセルを取得する
let sortedValue = temp.sorted { $0.value < $1.value }
guard let dateItem = sortedValue.first?.date,
let convertedDate = shared.toDateFormatter.date(from: dateItem) else { return }
// もっといい比較をしたかった……
// messageの値は「今日」などの文字。それと異なっているかどうかで判断している。
if message != shared.relativeDateFormatter.string(from: convertedDate) {
message = shared.relativeDateFormatter.string(from: convertedDate)
}
}
}
}
bodyは組み立てだけ。
var body: some View {
VStack {
Text("始端")
Divider()
Text(message)
.font(.title2)
HStack {
scroll
}
.frame(width: 300, height: 180)
Divider()
Text("終端")
}
}
全文
import SwiftUI
class ApplicationManager {
static let shared = ApplicationManager()
// 本当は天気予報の情報なんだけれども、サンプルなのでまぁいいかなって。
static let scrollItem = [
ScrollCell(hoge: "1", fuga: "ほげ1", piyo: "2022-01-07"),
ScrollCell(hoge: "2", fuga: "ほげ2", piyo: "2022-01-07"),
ScrollCell(hoge: "3", fuga: "ほげ3", piyo: "2022-01-07"),
ScrollCell(hoge: "4", fuga: "ほげ4", piyo: "2022-01-07"),
ScrollCell(hoge: "5", fuga: "ほげ5", piyo: "2022-01-07"),
ScrollCell(hoge: "6", fuga: "ほげ6", piyo: "2022-01-08"),
ScrollCell(hoge: "7", fuga: "ほげ7", piyo: "2022-01-08"),
ScrollCell(hoge: "8", fuga: "ほげ8", piyo: "2022-01-08"),
ScrollCell(hoge: "9", fuga: "ほげ9", piyo: "2022-01-08"),
ScrollCell(hoge: "10", fuga: "ほげ10", piyo: "2022-01-08"),
ScrollCell(hoge: "11", fuga: "ほげ11", piyo: "2022-01-09"),
ScrollCell(hoge: "12", fuga: "ほげ12", piyo: "2022-01-09"),
ScrollCell(hoge: "13", fuga: "ほげ13", piyo: "2022-01-09"),
ScrollCell(hoge: "14", fuga: "ほげ14", piyo: "2022-01-09")
]
// 天気予報の日付を一旦Date型に変換させるためのもの
let toDateFormatter: DateFormatter
// 今日とか日付を表示するためのもの
let relativeDateFormatter: DateFormatter
private init() {
let toDateFormatter = DateFormatter()
toDateFormatter.locale = Locale(identifier: "ja_JP")
// 絶対にこのフォーマット
toDateFormatter.dateFormat = "yyyy-MM-dd"
self.toDateFormatter = toDateFormatter
let relativeDateFormatter = DateFormatter()
relativeDateFormatter.locale = Locale(identifier: "ja_JP")
relativeDateFormatter.dateStyle = .medium
relativeDateFormatter.timeStyle = .none
relativeDateFormatter.doesRelativeDateFormatting = true
self.relativeDateFormatter = relativeDateFormatter
}
}
/// スクロールしているセル(雑)
struct ScrollCell: View {
let hoge: String
let fuga: String
let piyo: String
var body: some View {
VStack {
Text(hoge)
.padding()
Text(fuga)
.padding()
Text(piyo)
.padding()
}
}
}
struct ContentView: View {
let shared = ApplicationManager.shared
@State var message = "今日"
// スクロールの内部のセル(配列分のView)
var content: some View {
HStack {
ForEach(ApplicationManager.scrollItem.indices) { index in
// スクロール内部のGeometryReader
GeometryReader { insideProxy in
ApplicationManager.scrollItem[index]
.preference(key: ScrollOffsetPreferenceKey.self,
value: [ScrollValue(number: index,
value: insideProxy.frame(in: .global).minX,
date: ApplicationManager.scrollItem[index].piyo)]
)
}
.frame(width: 150, height: 180)
Divider()
}
}
}
// スクロール処理
var scroll: some View {
GeometryReader { outsideProxy in
ScrollView(.horizontal) {
content
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
// スクロールの左端
let side = outsideProxy.frame(in: .global).minX
// 見えなくなっているセルは対象外。sideよりも左にいっているセルも対象外。
let temp = value.filter{ side < $0.value }
// 左端に一番近いセルを取得したい
let sortedValue = temp.sorted { $0.value < $1.value }
guard let dateItem = sortedValue.first?.date,
let convertedDate = shared.toDateFormatter.date(from: dateItem) else { return }
// もっといい比較をしたかった……
if message != shared.relativeDateFormatter.string(from: convertedDate) {
message = shared.relativeDateFormatter.string(from: convertedDate)
}
}
}
}
var body: some View {
VStack {
Text("始端")
Divider()
Text(message)
.font(.title2)
HStack {
scroll
}
.frame(width: 300, height: 180)
Divider()
Text("終端")
}
}
}
struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: [ScrollValue] = []
static func reduce(value: inout [ScrollValue], nextValue: () -> [ScrollValue]) {
value.append(contentsOf: nextValue())
}
}
// PreferenceKey用の構造体(Equatableでないといけない)
struct ScrollValue: Equatable {
let number: Int
let value: CGFloat
let date: String
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
参考にさせていただいた記事
ありがとうございました。
「チュートリアルから一歩踏み出したSwiftUIのCustom Viewの作り方ーその2(PreferenceKey編)」
「SwiftUI: How to get content offset from ScrollView」