SwiftUI Custom PTR

  • Meta의 Threads 앱 홈 화면 상단에는 로고가 있고, 새로고침을 하면 미려하게 움직입니다.
  • SwiftUI로 이와 같은 커스텀 Pull To Refresh 인디케이터를 만들어 보겠습니다.

Threads 앱

  • Threads 앱의 PTR(Pull-to-refresh) 인디케이터는 다음과 같습니다.

swiftui-custom-ptr

커스텀 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
                        }
                    }
            }
        }
    }
}

결과물

swiftui-custom-ptr