feat(refresh): Lottie 새로고침 표시를 적용한다

This commit is contained in:
Yu Sung
2026-08-26 01:21:26 +09:00
parent fc0f7e2b50
commit e46198f87a
14 changed files with 1046 additions and 297 deletions

View File

@@ -0,0 +1,27 @@
import SwiftUI
import Lottie
struct LottieRefreshIndicator: View {
let isAnimating: Bool
@Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
var body: some View {
let animation = LottieView(animation: .named("pull-to-refresh"))
.configure(\.contentMode, to: .scaleAspectFit)
.resizable()
Group {
if accessibilityReduceMotion {
animation.currentFrame(1)
} else if isAnimating {
animation.looping()
} else {
animation.currentFrame(0)
}
}
.frame(maxWidth: SodaSpacing.s48, maxHeight: SodaSpacing.s48)
.accessibilityHidden(true)
}
}

View File

@@ -0,0 +1,119 @@
import SwiftUI
struct LottieRefreshableScrollView<Content: View>: View {
private let action: () async -> Void
private let content: () -> Content
private let refreshRegionHeight = SodaSpacing.s48 + (SodaSpacing.s8 * 2)
@Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
@State private var pullOffset: CGFloat = 0
@State private var isRefreshArmed = false
@State private var isRefreshing = false
init(
action: @escaping () async -> Void,
@ViewBuilder content: @escaping () -> Content
) {
self.action = action
self.content = content
}
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
pullOffsetReader
refreshRegion
content()
}
}
.scrollBounceBehavior(.always, axes: .vertical)
.background(PullOffsetPositionReader(type: .fixed))
.onPreferenceChange(PullOffsetPreferenceKey.self) { positions in
guard
let movingMinY = positions.first(where: { $0.type == .moving })?.minY,
let fixedMinY = positions.first(where: { $0.type == .fixed })?.minY
else { return }
let offset = max(0, movingMinY - fixedMinY)
pullOffset = offset
guard !isRefreshing else { return }
if offset >= refreshRegionHeight {
isRefreshArmed = true
} else if isRefreshArmed {
isRefreshArmed = false
refresh()
}
}
.accessibilityAction(named: Text(I18n.LiveNow.refreshButton)) {
refresh()
}
}
private var pullOffsetReader: some View {
PullOffsetPositionReader(type: .moving)
.frame(height: 0)
}
private var refreshRegion: some View {
Color.clear
.frame(height: isRefreshing ? refreshRegionHeight : 0)
.overlay(alignment: .top) {
if pullOffset > 0 || isRefreshing {
LottieRefreshIndicator(isAnimating: true)
.padding(.vertical, SodaSpacing.s8)
.frame(maxWidth: .infinity)
.frame(height: refreshRegionHeight)
.offset(y: isRefreshing ? 0 : -refreshRegionHeight)
}
}
}
private func refresh() {
isRefreshArmed = false
guard !isRefreshing else { return }
withAnimation(accessibilityReduceMotion ? nil : .easeOut(duration: 0.2)) {
isRefreshing = true
}
Task { @MainActor in
await action()
withAnimation(accessibilityReduceMotion ? nil : .easeOut(duration: 0.2)) {
pullOffset = 0
isRefreshing = false
}
}
}
}
private enum PullOffsetPositionType {
case fixed
case moving
}
private struct PullOffsetPosition: Equatable {
let type: PullOffsetPositionType
let minY: CGFloat
}
private struct PullOffsetPreferenceKey: PreferenceKey {
static var defaultValue: [PullOffsetPosition] = []
static func reduce(value: inout [PullOffsetPosition], nextValue: () -> [PullOffsetPosition]) {
value.append(contentsOf: nextValue())
}
}
private struct PullOffsetPositionReader: View {
let type: PullOffsetPositionType
var body: some View {
GeometryReader { proxy in
Color.clear.preference(
key: PullOffsetPreferenceKey.self,
value: [PullOffsetPosition(type: type, minY: proxy.frame(in: .global).minY)]
)
}
}
}