1
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 3 years have passed since last update.

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

1
Posted at

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

やりたいのは次のような動きらしい。(2022/01/07の動作)
qiita001.gif

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

履歴

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」

1
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
1
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?