SwiftUI로 Threads 앱 같은 커스텀 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