๐ŸŽ iOS/SwiftUI

[SwiftUI] Custom Picker

JerryiOS 2023. 12. 22. 09:52

import SwiftUI
import SwiftUIIntrospect

enum BirthPickerType {
    case year
    case month
}

struct BirthPickerView: View {
    
    let type: BirthPickerType
    @Binding var selectedNumber: Int
    
    let years = Array(1923...2022)
    let months = Array(1...12)
    
    @State private var originOffset: CGFloat = 0
    @State private var lastOffset: CGFloat = 0
    @State private var currentOffset: CGFloat = 0
    @State private var isCheckedOriginOffset: Bool = false
    @State private var isCheckedLastOffset: Bool = false
    @State private var isScrolling: Bool = false

    init(type: BirthPickerType, selectedNumber: Binding<Int>) {
        self.type = type
        _selectedNumber = selectedNumber
    }
    
    private var selection: [Int] {
        switch type {
        case .year:
            return years
        case .month:
            return months
        }
    }
    
    var body: some View {
        ZStack {
            doubleLine
            ScrollViewReader { proxy in
                ScrollView(.vertical, showsIndicators: false) {
                    scrollObservableView
                    scrollViewContents
                }
                .scrollStatusByIntrospect(isScrolling: $isScrolling)
                .clipped()
                .onPreferenceChange(ScrollOffsetKey.self) {
                    setOffset($0)
                }
                .onChange(of: isScrolling) { isScrolling in
                    if !isScrolling {
                        moveScroll(proxy)
                    }
                }
                .onAppear {
                    Task {
                        try await Task.sleep(nanoseconds: 50_000_000)
                        withAnimation {
                            proxy.scrollTo(selectedNumber, anchor: .center)
                        }
                        if !isCheckedLastOffset {
                            self.lastOffset = currentOffset
                            isCheckedLastOffset = true
                        }
                    }
                }
            }
        }
        .frame(width: 120, height: 120)
    }
}

// MARK: - Private Views
extension BirthPickerView {
    @ViewBuilder
    private var doubleLine: some View {
        VStack(spacing: 0) {
            Rectangle()
                .foregroundColor(.theme.grey030)
                .frame(height: 2)
            Spacer()
                .frame(height: 40)
            Rectangle()
                .foregroundColor(.theme.grey030)
                .frame(height: 2)
        }
    }
    
    @ViewBuilder
    private var scrollViewContents: some View {
        LazyVStack(spacing: 0) {
            ForEach(selection, id: \.self) { number in
                Text(makeSelectedItemLabel(number))
                    .foregroundColor(number == selectedNumber ? .theme.grey080 : .theme.grey030)
                    .font(.headline2)
                    .frame(width: 120, height: 40)
                    .id(number)
            }
        }
        .padding(.vertical, 56)
    }
    
    private var scrollObservableView: some View {
        GeometryReader { proxy in
            let offsetY = proxy.frame(in: .global).origin.y
            Color.clear
                .preference(
                    key: ScrollOffsetKey.self,
                    value: offsetY
                )
                .onAppear {
                    setOriginOffset(offsetY)
                }
        }
        .frame(height: 0)
    }
}

// MARK: - Private func
extension BirthPickerView {
    // ๋‚˜ํƒ€๋‚ ๋•Œ ๋ทฐ์˜ ์ตœ์ดˆ์œ„์น˜๋ฅผ ์ €์žฅํ•˜๋Š” ๋กœ์ง
    private func setOriginOffset(_ offset: CGFloat) {
        guard !isCheckedOriginOffset else { return }
        self.originOffset = offset
        isCheckedOriginOffset = true
    }
    
    private func setOffset(_ offset: CGFloat) {
        self.currentOffset = offset
    }
    
    private func moveScroll(_ proxy: ScrollViewProxy) {
        let diff = lastOffset - currentOffset
        
        let selectedIdx: Int
        if diff <= 0 {
            selectedIdx = 0
        } else if diff > CGFloat(selection.count - 1) * 40 {
            selectedIdx = selection.count - 1
        } else {
            selectedIdx = Int(diff / 40)
        }
        
        let selectedValue = selection[selectedIdx]
        
        withAnimation(.easeInOut(duration: 0.2)) {
            proxy.scrollTo(selectedValue, anchor: .center)
            selectedNumber = selectedValue
        }
    }
    
    private func makeSelectedItemLabel(_ num: Int) -> LocalizedStringKey {
        switch type {
        case .year:
            return "\(String(num))"
        case .month:
            return "\(num)์›”"
        }
    }
}

// MARK: - PreferenceKey

struct ScrollOffsetKey: PreferenceKey {
    static var defaultValue: CGFloat = .zero
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value += nextValue()
    }
}

#Preview {
    BirthPickerView(type: .month, selectedNumber: .constant(1))
}
๋ฐ˜์‘ํ˜•