- Meta의 Threads 앱 홈 화면 상단에는 로고가 있고, 새로고침을 하면 미려하게 움직입니다.
- SwiftUI로 이와 같은 커스텀 Pull To Refresh 인디케이터를 만들어 보겠습니다.
Threads 앱
- Threads 앱의 PTR(Pull-to-refresh) 인디케이터는 다음과 같습니다.
커스텀 PTR 만들기
- 테스트에 사용한 완성된 코드는 다음과 같습니다.
- 아래에서 하나씩 살펴보겠습니다.
struct ContentView: View {
@Environment(\.safeAreaInsets) private var safeAreaInsets
@State private var elements = Array(0..<30).map { String($0) }
@State private var yOffset: CGFloat = 0
private let config = RefreshConfig()
var body: some View {
List(Array(elements.enumerated()), id: \.element) { index, item in
Text(item)
.frame(height: 100)
.listRowInsets(.zero)
.padding(.horizontal)
.if (index == 0) {
$0.onChangeOffsetY { self.yOffset = $0 }
}
}
.safeAreaInset(edge: .top, spacing: 0) {
RefreshIconView(
yOffset: $yOffset,
config: config,
headerInset: safeAreaInsets.top + config.headerHeight
) {
try? await Task.sleep(for: .seconds(1))
} refreshView: { state in
IconView(state: state)
}
}
.listStyle(.plain)
}
}
유틸성 구현
- 테스트 코드에 사용된
if
modifier 입니다.
extension View {
@ViewBuilder
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
- View의 y offset 변화 감지를 트래킹 하기 위한
onChangeOffsetY
modifier 입니다.
extension View {
func onChangeOffsetY(coordinateSpace: CoordinateSpace = .global, onChange: @escaping (CGFloat) -> Void) -> some View {
self.overlay {
GeometryReader { proxy in
Color.clear
.preference(key: OffsetYPreferenceKey.self, value: proxy.frame(in: coordinateSpace).minY)
.onPreferenceChange(OffsetYPreferenceKey.self) { value in
onChange(value)
}
}
}
}
}
struct OffsetYPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
- Safe area를 쉽게 가져오기 위한 environment value 구현입니다.
extension EnvironmentValues {
var safeAreaInsets: EdgeInsets {
self[SafeAreaInsetsKey.self]
}
}
struct SafeAreaInsetsKey: EnvironmentKey {
static var defaultValue: EdgeInsets {
let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene
let window = windowScene?.windows.first
return window?.safeAreaInsets.swiftUiInsets ?? EdgeInsets()
}
}
extension UIEdgeInsets {
var swiftUiInsets: EdgeInsets {
EdgeInsets(top: top, leading: left, bottom: bottom, trailing: right)
}
}
- 기본 zero inset을 위한 확장 구현입니다.
extension EdgeInsets {
static var zero: EdgeInsets { .init(top: 0, leading: 0, bottom: 0, trailing: 0) }
}
PTR 본격 구현
- 본격적인 세팅으로, PTR을 구현하기 위한 정의 부분입니다.
typealias RefreshAction = () async -> ()
enum RefreshMode {
case notRefreshing
case pulling
case refreshing
}
struct RefreshState {
var mode: RefreshMode = .notRefreshing
var dragPosition: CGFloat = 0
}
struct RefreshConfig {
var refreshAt: CGFloat = 100
var headerHeight: CGFloat = 80
var resetPoint: CGFloat = 5
}
- List 화면 최상단에 위치했던 RefreshIconView 구현입니다.
- 실질적인 refreshAction과 refreshView를 연결해주는 부분입니다.
struct RefreshIconView<RefreshView: View>: View {
@Binding var yOffset: CGFloat
let config: Config
let headerInset: CGFloat
let refreshAction: RefreshAction
let refreshView: (Binding<RefreshState>) -> RefreshView
@State private var state = RefreshState()
@State private var distance: CGFloat = 0
@State private var canRefresh = true
@State private var pastYOffset: CGFloat = 0
private var iconOffset: CGFloat {
config.headerHeight * state.dragPosition
}
var body: some View {
refreshView($state)
.opacity(canRefresh ? 1 : 0.6)
.frame(height: config.headerHeight)
.offset(y: iconOffset)
.onChange(of: yOffset) { value in
offsetChanged(value)
pastYOffset = value
}
}
private func offsetChanged(_ val: CGFloat) {
distance = val - headerInset
state.dragPosition = normalize(from: 0, to: config.refreshAt, by: distance)
guard canRefresh else {
canRefresh = (distance <= config.resetPoint) && (state.mode == .notRefreshing)
return
}
guard distance > 0 else {
state.mode = .notRefreshing
return
}
if distance >= config.refreshAt {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
state.mode = .refreshing
withAnimation { canRefresh = false }
Task {
await refreshAction()
withAnimation { canRefresh = true }
}
} else if distance > 0 && val > pastYOffset {
state.mode = .pulling
}
}
}
- Refresh Icon의 실질적인 UI 부분입니다.
struct IconView: View {
@State private var scaling = false
@Binding var state: RefreshState
var body: some View {
VStack {
switch state.mode {
case .notRefreshing:
Image(.hohyeon)
.resizable()
.frame(width: 50, height: 50)
.onAppear {
withAnimation {
scaling = false
}
}
case .pulling:
Image(.hohyeon)
.resizable()
.frame(width: 50, height: 50)
.rotationEffect(.degrees(360 * state.dragPosition))
case .refreshing:
Image(.hohyeon)
.resizable()
.frame(width: 50, height: 50)
.scaleEffect(scaling ? 2 : 1)
.task {
withAnimation {
scaling = true
}
try? await Task.sleep(for: .seconds(0.2))
withAnimation {
scaling = false
}
}
}
}
}
}
결과물