概要
swiftUIで pull to refresh
を実現するには
Apple純正のrefreshableを使うのが一般的です。
ただ、自分が作成していたアプリではこれらを使用することで、レイアウト崩れや予期しない動作をしていました。
(最後の方に純正refreshableでおきた同様の問題の記事をまとめているので参考になれば幸いです。)
そのためcustomRefreshable
を作ろうと思ったのですが、
これらの知見が少なく情報も古かったため何を参考にどう作ったのかをメモついでに共有できたらなと思います。
閾値等については、作成したアプリに合わせて設定しているためその都度よしなに変えていただければなと思います。
実際のコード
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
)
}
}
}
}
}
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
の代わりに使えると思います。
実際に自分の環境でも以下のように置き換えています。
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
)
}
}
}
}
}
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関連の記事