SwiftUIでswipe→progress view表示→データ更新→更新完了次第 progress view非表示を実現する

# 環境
Xcode…Version 12.5 (12E262)

# やりたかったことと経緯
**ScrollViewにデータを表示しておいて、スワイプされたらデータ更新。
progress view(くるくる)を表示する。
新しいデータを取得完了次第progress viewを表示終了する。**

というのを実現したかったのだが、そもそもSwiftUIはprogress view単体はあるもの(`ProgessView`というstruct)の、「データ更新終了次第消す」機能どころか「スワイプされたらくるくるを出す」機能(androidでいうSwipeRefreshLayout、　UIKitのiOSでいうUIRefreshControl?)すらまだデフォルトでの用意はないらしい。なので自作した。

# コード

## 1.progress viewを表示する側の準備をする①

```swift
struct TopView: View {
    @StateObject var apiModel :ApiModel 
    
    
    var body: some View {
        VStack{
            if(self.apiModel.memos.count>0){
                MemoList(memos: self.apiModel.memos).environmentObject(apiModel)
            }else{
                EmptyView()
            }
        }
        .coordinateSpace(name: "parent")
        .onAppear(){
            // 表示したら読み込み開始
            self.apiModel.load()
        }
    }
}

```

```swift
class ApiModel: ObservableObject {
    @Published var memos : Array<String> = []
    
    init() {
        
    }
    
    
    private let API_BASE =
        "https://hogehoge"
    
    private let API_PATH_GET_ALL_DATA="/apiNameGetAll"

    
    public func load(memosContainer : Binding<Array<String>>? = nil) {
        let url = URL(string: API_BASE+API_PATH_GET_ALL_DATA)!
        
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            print("data: \(String(describing: data))")
            do{
                let allData = try JSONSerialization.jsonObject(with: data!, options: JSONSerialization.ReadingOptions.allowFragments)
                print(allData) // Jsonの中身を表示
            }
            catch {
                print(error)
            }
            DispatchQueue.main.async {
                if(memosContainer == nil){
                    self.memos = try! JSONDecoder().decode(Array<String>.self, from: data!)
                }else{
                    memosContainer!.wrappedValue =    try! JSONDecoder().decode(Array<String>.self, from: data!)
                }
            }
        }.resume()
    }
    
}

```


- `TopView` : 大元のView。`MemoList`はScrollViewを持ち、中でデータをViewにして表示している。(具体的コードは後述。)
- `ApiModel` : http通信をして外からデータを取得してくるメソッド`load(memosContainer : Binding<Array<String>>? = nil)`と取得してきたデータの入っている`@Published var memos : Array<String>`を持つ。
- `load(memosContainer : Binding<Array<String>>? = nil)`は、memosContainerが渡されなかったら取得してきたデータを`@Published var memos : Array<String>`に入れるが、not-nilだったら、memosContainerにデータを入れる。
- 以上のsnipetでのポイントは`.coordinateSpace(name: "parent")`。これがあとでprogress viewを出す時にきいてくる。

## 2.progress viewを表示するための準備②+データ更新終了時にprogress viewの表示を消す部分の実装

```swift
struct  MemoList:View {
    var memos : Array<String>
    @EnvironmentObject var apiModel :ApiModel
    
    init(memos : Array<String>) {
        self.memos = memos
    }
    
    var body: some View{
        var swipe : SwipeRefreshController?
        
        // 中間橋渡しをしてくれるオブジェクトを作ることで、データが更新されたらクルクルを消せるようにする。
        let p = Binding<Array<String>>(get : {
            return self.apiModel.memos
        },set : { memos in
            swipe?.finishRefresh() // データ取得が終わったのでクルクルを止める
            self.apiModel.memos = memos
            
        })
        
        swipe = SwipeRefreshController(coordinateSpaceName:"parent"){
            self.apiModel.load(memosContainer: p)
        }
        
        return ScrollView(.vertical){
            VStack{
                swipe
                
                ForEach(self.memos, id: \.self){ memo in
                    Text(memo)
                    
                    
                }
            }
        }
    }
}
```


- `MemoList ` : ScrollViewにデータを表示していくView。
- また、今回の主役のprogress viewをもつViewである`SwipeRefreshController`もScrollViewの中に入れて表示する。
- SwipeRefreshControllerはinit時に`(coordinateSpaceName : String,onRefresh : @escaping (()->Void)`を引数に取る。これは引っ張られたかどうか判断するために親のcoordinateSpaceのnameを渡すための`coordinateSpaceName : String`と、progress viewを表示した時の表示開始(正確にはスワイプされた時)コールバックの`onRefresh : @escaping (()->Void)`。(コード詳細後述)
- また、SwipeRefreshControllerは`finishRefresh()`を呼ぶとprogress viewを表示終了する。(コード詳細後述)
- 以上のsnipetでのポイントは、まず、「progress viewに表示するための準備①」でTopViewに設定したcoordinateSpace nameの`"parent"`を`SwipeRefreshController`インスタンス作成時に渡していること。
- 更に

```swift
let p = Binding<Array<String>>(get : {
            return self.apiModel.memos
        },set : { memos in
            swipe?.finishRefresh() // データ取得が終わったのでクルクルを止める
            self.apiModel.memos = memos
            
        })
```
を作成し、`SwipeRefreshController`のprogress view表示開始コールバックに`self.apiModel.load(memosContainer: p)`を設定したこと。これにより、スワイプされてprogress viewが表示されたら、データの更新`ApiModel#load(memosContainer : Binding<Array<String>>? = nil)`が呼ばれ、さらに今回はmemosContainer!=nilなのでデータ更新され次第`p`にとってきたデータが入り、`p#set`が呼ばれる。

- そして`p#set`の中で`SwipeRefreshController#finishRefresh()`を呼ぶことで、「データの更新が終了し次第progress viewの表示を終了」を実現。さらに`self.apiModel.memos = memos`を呼ぶことで`TopView`や`MemoList`が表示に使用しているおおもとのデータも更新する。

## 3.スワイプされたら感知してprogress viewを表示するViewの作成

```swift
class RefreshManager : ObservableObject {
    @Published var isRefreshing = false
}

// スワイプしたら更新中のクルクルをだしてリフレッシュコールバックを呼ぶためのView
struct SwipeRefreshController: View {
    init(coordinateSpaceName : String,onRefresh : @escaping (()->Void)) {
        // クロージャがスコープ外でも生きる時は@escapeが必要　https://qiita.com/mishimay/items/1232dbfe8208e77ed10e#%E3%81%A9%E3%81%86%E3%81%84%E3%81%86%E3%81%A8%E3%81%8D%E3%81%AB%E5%BF%85%E8%A6%81%E3%81%8B
        self.coordinateSpaceName = coordinateSpaceName
        self.onRefresh = onRefresh
        //isRefreshing = false
    }
    
    @ObservedObject var refreshManager = RefreshManager() // @Stateでただのbool値にしていると、外からfalseにしても反映されなかったので、ObservaleObjectクラスを作ってその中のプロパティにする。
    
    // クルクルを消す
    public func finishRefresh(){
        print("finishRefresh")
        self.refreshManager.isRefreshing = false
    }
    var coordinateSpaceName: String
    var onRefresh: () -> Void // スワイプをした時のコールバック
    
    
    var body: some View {
        return GeometryReader { geometry in
            VStack{
                if geometry.frame(in: .named(coordinateSpaceName)).midY > 100 {
                    Spacer()
                        .onAppear() {
                            //isRefreshing = true
                            self.refreshManager.isRefreshing = true
                            onRefresh()
                        }
                }
                if (self.refreshManager.isRefreshing) {
                    HStack {
                        Spacer()
                        
                        ProgressView()
                        
                        Spacer()
                    }
                }
            }
        }
    }
}
```
- 渡されたcoordinateSpaceNameと、GeometryReaderでスワイプされたかどうか判定して、スワイプされたら`class RefreshManager : ObservableObject`に持たせたデータ更新中フラグの`@Published var isRefreshing`をたて、`init`時に渡されていたスワイプ時コールバックを呼ぶ。
- また、`isRefreshing`フラグが立ったらprogress viewを表示し、おろされたら表示を終了する。


以上で、
**ScrollViewにデータを表示しておいて、スワイプされたらデータ更新。
progress view(くるくる)を表示する。
新しいデータを取得完了次第progress viewを表示終了する。**
を実現できました。

# 参考にしたもの
https://www.yururiwork.net/%E3%80%90swiftui%E3%80%91pull-to-refresh%EF%BC%88uirefreshcontrol%EF%BC%89%E3%82%92%E5%AE%9F%E8%A3%85%E3%81%99%E3%82%8B/
https://qiita.com/masa7351/items/0567969f93cc88d714ac