2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

swiftUIでCustomRefreshableを作る

Last updated at Posted at 2024-04-04

概要

swiftUIで pull to refresh を実現するには
Apple純正のrefreshableを使うのが一般的です。

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

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

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

refreshable.gif

実際のコード

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
                        )
                }
            }
        }
    }
}
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の代わりに使えると思います。
実際に自分の環境でも以下のように置き換えています。

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だったこと+ 元々実装していた部分との兼ね合い
等を考慮したところこちらの記事が簡単にできそうだったため、.onReceiveで実装しました。

isReadyの役割

コードとしては、以下みたいな実装すればいけました。

.onReceive(NotificationCenter.default.publisher(for: .hideRefreshable)) { _ in
        isRefreshing = false
        animate = false
        trimEnd = 0.6
        axis = .vertical
}

他の手段としては、async/awaitで実装するか
こちらのgithubにあるRefreshCompleteとかを実装するのがいいと思います。

参考にした記事

純正refreshable関連の記事

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?