swiftUIでCustomRefreshableを作る

## 概要

swiftUIで `pull to refresh` を実現するには
[Apple純正のrefreshable]("https://developer.apple.com/documentation/swiftui/view/refreshable(action:)")を使うのが一般的です。

ただ、自分が作成していたアプリではこれらを使用することで、レイアウト崩れや予期しない動作をしていました。
（最後の方に純正refreshableでおきた同様の問題の記事をまとめているので参考になれば幸いです。）

そのため`customRefreshable`を作ろうと思ったのですが、
これらの知見が少なく情報も古かったため何を参考にどう作ったのかをメモついでに共有できたらなと思います。

閾値等については、作成したアプリに合わせて設定しているためその都度よしなに変えていただければなと思います。

![refreshable.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3535886/7c9e4916-040a-389a-d8c1-bad6164db2da.gif)



## 実際のコード
```swift:ContentView.swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        RefreshableScrollView(isAnimation: true, action: {}) {
            LazyVStack {
                ForEach(0 ..< 200) { i in
                    Text(i.description)
                        .frame(width: 200, height: 40)
                        .background(
                            Color.blue
                        )
                }
            }
        }
    }
}
```

```swift:RefreshableScrollView.swift
import SwiftUI

struct RefreshableScrollView<Content: View>: View {
    private let defaultThreshold: CGFloat
    private let indicatorSize: CGFloat = 25
    private let indicatorColor: Color = .black
    private let strokeStyle: StrokeStyle = .init(lineWidth: 3, lineCap: .round, lineJoin: .round)

    var offset: CGFloat
    var showsIndicators: Bool
    var isAnimation: Bool
    var action: () -> Void
    var content: Content

    @State private var trimEnd = 0.6
    @State private var animate = false
    @State private var isReady = true
    @State private var isRefreshing = false
    @State private var scrollOffset: CGFloat = 0
    @State private var axis: Axis.Set = .vertical

    private var threshold: CGFloat {
        defaultThreshold + offset
    }

    private var indicatorOpacity: Double {
        let offset = -scrollOffset
        if offset <= 0 {
            return 0
        }
        let sqrt = sqrt(offset)
        let ratio = offset / (threshold / 2)
        let pow = pow(sqrt, ratio)
        let result = pow / threshold
        return result > 1 ? 1 : result
    }

    init(
        offset: CGFloat = 0,
        showsIndicators: Bool = true,
        isAnimation: Bool = false,
        action: @escaping () -> Void,
        @ViewBuilder content: () -> Content
    ) {
        self.offset = offset
        self.showsIndicators = showsIndicators
        self.isAnimation = isAnimation
        self.action = action
        self.content = content()

        defaultThreshold = offset > 0 ? 25 : 75
    }

    var body: some View {
        GeometryReader { outsideProxy in
            ScrollView(axis, showsIndicators: showsIndicators) {
                ZStack(alignment: .top) {
                    GeometryReader { insideProxy in
                        Color.clear
                            .preference(
                                key: ScrollOffsetPreferenceKey.self,
                                value: [
                                    outsideProxy.frame(in: .global).minY,
                                    insideProxy.frame(in: .global).minY,
                                ]
                            )
                    }

                    content
                        .alignmentGuide(.top, computeValue: { _ in
                            isRefreshing ? -threshold + max(0, scrollOffset) : 0
                        })
                    if isReady {
                        loadingIndicator
                            .offset(y: isRefreshing ? -max(0, scrollOffset) : -threshold)
                    }
                }
            }
            .modifier(ScrollDisabledModifier(isRefreshing: isRefreshing))
        }
        .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
            let scrollOffset = value[0] - value[1]
            self.scrollOffset = scrollOffset
            if !isReady {
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                    isReady = scrollOffset == 0 && self.scrollOffset == 0
                }
            }
            if scrollOffset > 0 {
                isReady = false
            }
            if scrollOffset <= -threshold && isReady && !isRefreshing {
                isRefreshing = true
                axis = []
                action()
                playImpactFeedback(.medium)

                // 必要に応じて都度実装を変更
                DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
                    // actionの処理が終わった時に発火させる
                    withAnimation(Animation.easeIn(duration: isAnimation ? 0.1 : 0)) {
                        isRefreshing = false
                        animate = false
                        trimEnd = 0.6
                        axis = .vertical
                    }
                }
            }
        }
    }

    private var loadingIndicator: some View {
        ZStack(alignment: offset > 0 ? .bottom : .center) {
            Color.clear
                .frame(height: threshold)

            if isRefreshing {
                Circle()
                    .trim(from: 0, to: trimEnd)
                    .stroke(indicatorColor, style: strokeStyle)
                    .frame(width: indicatorSize, height: indicatorSize)
                    .animation(
                        Animation.easeIn(duration: 1.5)
                            .repeatForever(autoreverses: true),
                        value: trimEnd
                    )
                    .rotationEffect(Angle(degrees: animate ? 270 + 360 : 270))
                    .animation(
                        Animation.linear(duration: 1)
                            .repeatForever(autoreverses: false),
                        value: animate
                    )
                    .onAppear {
                        animate = true
                        trimEnd = 0
                    }
            } else {
                Circle()
                    .trim(from: 0, to: -scrollOffset / threshold)
                    .stroke(indicatorColor, style: strokeStyle)
                    .frame(width: indicatorSize, height: indicatorSize)
                    .rotationEffect(.degrees(-90))
                    .opacity(indicatorOpacity)
            }
        }
    }
}

private func playImpactFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
    let generator = UIImpactFeedbackGenerator(style: style)
    generator.impactOccurred()
}

private struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue = [CGFloat]()

    static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
        value.append(contentsOf: nextValue())
    }
}

private struct ScrollDisabledModifier: ViewModifier {
    let isRefreshing: Bool

    func body(content: Content) -> some View {
        if #available(iOS 16.0, *) {
            content
                .scrollDisabled(isRefreshing)
        } else {
            content
        }
    }
}
```

## 解説(時間がある時に徐々に更新予定)

- ### 使い方

基本的な使用方法としては、`ScrollView`の代わりに使えると思います。
実際に自分の環境でも以下のように置き換えています。


```diff_swift:ContentView.swift
import SwiftUI

struct ContentView: View {
    var body: some View {
-       ScrollView {
+       RefreshableScrollView(isAnimation: true, action: {}) {
            LazyVStack {
                ForEach(0 ..< 200) { i in
                    Text(i.description)
                        .frame(width: 200, height: 40)
                        .background(
                            Color.blue
                        )
                }
            }
        }
    }
}
```



- ### onPreferenceChange

```
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
    // actionの処理が終わった時に発火させる
    withAnimation(Animation.easeIn(duration: isAnimation ? 0.1 : 0)) {
        isRefreshing = false
        animate = false
        trimEnd = 0.6
        axis = .vertical
    }
}
```
こちらのコードですが、動作確認用に作っていたコードのため本来はもっと
ここの処理はこだわらないといけないです。

自分が使っていたアーキテクチャがTCAだったこと+ 元々実装していた部分との兼ね合い
等を考慮したところ[こちらの記事](https://qiita.com/k-yamada-github/items/e363407daea82b8e6dc0)が簡単にできそうだったため、[.onReceive](https://developer.apple.com/documentation/swiftui/view/onreceive(_:perform:))で実装しました。


### isReadyの役割



コードとしては、以下みたいな実装すればいけました。
```
.onReceive(NotificationCenter.default.publisher(for: .hideRefreshable)) { _ in
        isRefreshing = false
        animate = false
        trimEnd = 0.6
        axis = .vertical
}
```

他の手段としては、async/awaitで実装するか
こちらの[github](https://github.com/globulus/swiftui-pull-to-refresh)にあるRefreshCompleteとかを実装するのがいいと思います。


## 参考にした記事

https://qiita.com/k-yamada-github/items/f6ad6071854336f173f9

https://github.com/globulus/swiftui-pull-to-refresh

https://blog.stackademic.com/custom-progressview-in-swiftui-c94f51ba598b

https://zenn.dev/shota_xx/articles/5df397376a2757

https://qiita.com/SNQ-2001/items/d0a2a166a23e92dc6680


## 純正refreshable関連の記事

https://qiita.com/uhooi/items/c0bff1724856f0eef226#%E6%82%AA%E3%81%84%E4%BE%8B%E3%82%92%E4%BF%AE%E6%AD%A3%E3%81%99%E3%82%8B

https://stackoverflow.com/questions/76943913/swiftui-scrollview-refreshable-doesnt-work-appropriately

https://forums.developer.apple.com/forums/thread/707510