ScrollViewの左端にあるセルの情報で他のViewの表示を変更する

# ScrollViewに表示されている情報でTextの値を変えたい

やりたいのは次のような動きらしい。（2022/01/07の動作）
![qiita001.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/421964/7b400c77-484a-ff65-b4fb-8c5986d2caf3.gif)

本来は天気予報の日付を使うとのこと。

## 履歴
2022年1月7日 初版

## 開発環境
Swift5.5以降
Xcode13

## 手法
内部のセルが、ScrollViewの左端に掛かったら、そのセルの情報で他のViewを置き換える


## ScrollViewの左端と内部のセルの比較

基本的に親（ScrollView)→子（内部のセル）の流れでデータは流れるので、子（内部のセル）→親（ScrollView）にデータは流せない。
子→親に情報を渡すにはPreferenceKeyを使う。

## セル

スクロールさせるセルの構造体を作る。

```swift
/// スクロールしているセル（雑）
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から天気情報を取得するけれども、今回はベタ書きで作成。
試したい方は日付を変更してください。

その他、日付のフォーマッターはアプリ起動中一つで良いのでシングルトンで作成する。

```swift
class ApplicationManager {
    
    static let shared = ApplicationManager()
    
    // 本当は天気予報の情報なんだけれども、サンプルなのでまぁいいかなって。
    static let scrollItem = [
        ScrollCell(hoge: "1", fuga: "ほげ１", piyo: "2022-01-07"),
        ScrollCell(hoge: "2", fuga: "ほげ２", piyo: "2022-01-07"),
        ScrollCell(hoge: "3", fuga: "ほげ３", piyo: "2022-01-07"),
        ScrollCell(hoge: "4", fuga: "ほげ４", piyo: "2022-01-07"),
        ScrollCell(hoge: "5", fuga: "ほげ５", piyo: "2022-01-07"),
        ScrollCell(hoge: "6", fuga: "ほげ６", piyo: "2022-01-08"),
        ScrollCell(hoge: "7", fuga: "ほげ７", piyo: "2022-01-08"),
        ScrollCell(hoge: "8", fuga: "ほげ８", piyo: "2022-01-08"),
        ScrollCell(hoge: "9", fuga: "ほげ９", piyo: "2022-01-08"),
        ScrollCell(hoge: "10", fuga: "ほげ１０", piyo: "2022-01-08"),
        ScrollCell(hoge: "11", fuga: "ほげ１１", piyo: "2022-01-09"),
        ScrollCell(hoge: "12", fuga: "ほげ１２", piyo: "2022-01-09"),
        ScrollCell(hoge: "13", fuga: "ほげ１３", piyo: "2022-01-09"),
        ScrollCell(hoge: "14", fuga: "ほげ１４", 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の実装は、今回のケースではしなくても大丈夫。


```swift
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.」`とデバッグメッセージが出る。



```swift
    // スクロールの内部のセル（配列分の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の部分）と値が変わっているのならば、更新をする。

```swift
    // スクロール処理
    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は組み立てだけ。

```swift
    var body: some View {
        VStack {
            Text("始端")
            Divider()
            Text(message)
                .font(.title2)
            HStack {
                scroll
            }
            .frame(width: 300, height: 180)
            Divider()
            Text("終端")
        }
    }
```

## 全文

```swift
import SwiftUI

class ApplicationManager {
    
    static let shared = ApplicationManager()
    
    // 本当は天気予報の情報なんだけれども、サンプルなのでまぁいいかなって。
    static let scrollItem = [
        ScrollCell(hoge: "1", fuga: "ほげ１", piyo: "2022-01-07"),
        ScrollCell(hoge: "2", fuga: "ほげ２", piyo: "2022-01-07"),
        ScrollCell(hoge: "3", fuga: "ほげ３", piyo: "2022-01-07"),
        ScrollCell(hoge: "4", fuga: "ほげ４", piyo: "2022-01-07"),
        ScrollCell(hoge: "5", fuga: "ほげ５", piyo: "2022-01-07"),
        ScrollCell(hoge: "6", fuga: "ほげ６", piyo: "2022-01-08"),
        ScrollCell(hoge: "7", fuga: "ほげ７", piyo: "2022-01-08"),
        ScrollCell(hoge: "8", fuga: "ほげ８", piyo: "2022-01-08"),
        ScrollCell(hoge: "9", fuga: "ほげ９", piyo: "2022-01-08"),
        ScrollCell(hoge: "10", fuga: "ほげ１０", piyo: "2022-01-08"),
        ScrollCell(hoge: "11", fuga: "ほげ１１", piyo: "2022-01-09"),
        ScrollCell(hoge: "12", fuga: "ほげ１２", piyo: "2022-01-09"),
        ScrollCell(hoge: "13", fuga: "ほげ１３", piyo: "2022-01-09"),
        ScrollCell(hoge: "14", fuga: "ほげ１４", 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編）」
https://qiita.com/takaf51/items/de86221493f313ebfc3c

### 「SwiftUI: How to get content offset from ScrollView」
https://medium.com/@maxnatchanon/swiftui-how-to-get-content-offset-from-scrollview-5ce1f84603ec