티스토리 뷰

 

원래 무한스크롤이라고 하면 맨 아래까지 스크롤하는 것을 트리거로 새로운 컨텐츠를 계속 로드해 화면에 붙여주는 형태를 말하는데요,

저는 이와 다르게 위의 끝까지 스크롤했을 때 위로 컨텐츠가 붙는 기능이 필요했습니다.. (이전 채팅 보기 기능)

이를 구현하기 위해 시도했던 다양한 방법들을 공유해보려고 합니다!

ios v16에 맞춰 17부터 사용 가능한 API들은 사용하지 않았습니다.

기존/반대

 

1. refreshable +  컨텐츠를 배열에 insert하기

먼저 떠올린 방법은 refresable을 이용하고 로드한 컨텐츠를 기존 배열 맨 앞에 insert해서 붙여주는 것입니다.

struct ChatScrollTest: View {
    struct Message: Hashable {
        let id: UUID = .init()
        let text: String
    }
    
    @State var text: String = ""
    
    @State var messages: [Message] = (0..<50).reversed().map { Message(text: String($0)) }
    
    func addMore() async {
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        DispatchQueue.main.async {
            if let lastMessage = messages.first?.text {
                let newMessages = (Int(lastMessage)!...Int(lastMessage)!+30).reversed().map { i in
                    Message(text: String(i))
                }
                messages.insert(contentsOf: newMessages, at: 0) // ✅
            }
        }
    }
    
    var body: some View {
        VStack {
            // chat layer
            ScrollView {
                LazyVStack {
                    ForEach(messages, id: \.self) { message in
                        Text("\(message.text)")
                            .id(message.id)
                    }
                }
                .background(Color.gray02)
            }
            .refreshable { // ✅
                await addMore()
            }
            
            // text field layer
            HStack { ... }
        }
    }
}

나쁘지는 않은 것 같지만, 스크롤 전에 보고 있던 위치를 잃어버리게 된다는 점이 아쉽습니다.

만약 채팅이라면 이전 채팅을 더 보려고 불러온 순간, 그전까지 읽던 위치를 놓치게 되어 그 지점부터 다시 찾아 읽어야겠죠??

 

2. ScrollViewReader로 이전 위치 되돌려주기

불러오기 전 첫번째 아이템의 id값을 기억해뒀다가 scrollProxy를 이용해 스크롤을 조정해주었습니다.

struct ChatScrollTest: View {
    struct Message: Hashable {
        let id: UUID = .init()
        let text: String
    }
    
    @State var text: String = ""
    @State var messages: [Message] = (0..<50).reversed().map { Message(text: String($0)) }
    @State var focusMessageID: UUID = .init() // ✅ 2
    
    func addMore() async {
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        DispatchQueue.main.async {
            if let lastMessage = messages.first?.text {
                let newMessages = (Int(lastMessage)!...Int(lastMessage)!+30).reversed().map { i in
                    Message(text: String(i))
                }
                messages.insert(contentsOf: newMessages, at: 0)
            }
        }
    }
    
    var body: some View {
        VStack {
            // chat layer
            ScrollViewReader { scrollProxy in // ✅ 1
                ScrollView {
                    LazyVStack {
                        ForEach(messages, id: \.self) { message in
                            Text("\(message.text)")
                                .id(message.id)
                        }
                    }
                    .background(Color.gray02)
                }
                .refreshable {
                    focusMessageID = messages.first?.id ?? .init() // ✅ 3
                    await addMore()
                    scrollProxy.scrollTo(focusMessageID, anchor: .top) // ✅ 4
                }
                
                // text field layer
                HStack { ... }
            }
        }
    }
}

 

로드속도 1초 이상

훨씬 괜찮아졌네요!!

하지만 문제가 있었습니다. 

로드속도 1초 이하

로드속도가 느릴때는 잘 자동하지만 로드속도가 매우 빠를때는 적용이 되지 않았습니다.

 

3. ScrollView를...뒤집어볼까..?

사실 일반적인 무한스크롤의 경우 새로 컨텐츠가 로드되어도 스크롤 값을 유지하게 되죠.

그런데 왜 위로 컨텐츠를 붙이면 다시 맨 위 스크롤 상태로 리셋되는 걸까요? (근데 당연하죠.. 원래 새로고침은 가장 최신걸 보여줘야하니까..)

어쨌던, 스크롤뷰가 배열의 상태를 트리거로 리로드 된다고 했을 때 [1, 2, 3] -> [1, 2, 3, 4]는 변화점이 4하나밖에 없지만 [1, 2, 3] -> [0, 1, 2, 3]은 모든 요소의 위치가 변화한 것이므로 전체를 다시 리로드한다고 생각했습니다.

 

그렇다면, 기본적인 무한스크롤뷰를 뒤집으면 되지 않을까요?

struct ChatScrollTest: View {
    struct Message: Hashable {
        let id: UUID = .init()
        let text: String
    }
    
    @State var text: String = ""
    @State var messages: [Message] = (0..<50).map { Message(text: String($0)) }
    
    func addMore() async {
        // try? await Task.sleep(nanoseconds: 1_000_000_000)
        DispatchQueue.main.async {
            if let lastMessage = messages.last?.text {
                let newMessages = (Int(lastMessage)!...Int(lastMessage)!+30).map { i in
                    Message(text: String(i))
                }
                messages.append(contentsOf: newMessages)
            }
        }
    }
    
    var body: some View {
        VStack {
            // chat layer
            ScrollView {
                LazyVStack {
                    ForEach(messages, id: \.self) { message in
                        Text("\(message.text)")
                            .id(message.id)
                            .scaleEffect(y: -1) // ✅ 원래대로 보이도록 내부에서 뒤집기
                            .onAppear { // ✅ 로드 트리거
                                if message.id == messages.first!.id {
                                    Task {
                                        await addMore()
                                    }
                                }
                            }
                    }
                }
                .background(Color.gray02)
            }
            .scaleEffect(y: -1) // ✅ 스크롤 뷰 전체를 뒤집기
                
                // text field layer
                HStack { ... }
            }
        }
    }
}

 

잘되네요!! 원래 스크롤 위치를 유지하면서도 새로운 데이터가 계속 로드되고 있어요 👍

 

 

하지만 이건 반대로 로드 속도가 느릴때 언제 로드될지 예측할 수 없어 사용감이 떨어지는 단점이 있었습니다...

 

4. 3번 + 리프레시 인디케이터

느린 로드에 대응하기 위해 스켈레톤 방식도 적용할 수 있지만, 처음 아이디어처럼 리프레시 인디케이터를 이용해보기로 했습니다.

물론, 현재 스크롤뷰가 위아래로 뒤집힌 상태이므로 기존의 .refreshable 모디파이어는 사용할 수 없습니다...🥹

custom refreshable을 구현하는 방법을 알아보다가 아래 영상에서 아이디어를 얻어 구현하게 되었습니다.

SwiftUI 2.0 Pull To Refresh - Custom Pull To Refresh Control - SwiftUI 2.0 Tutorials

 

fileprivate struct Refreshable {
    private let PENDING_THRESHOLD: CGFloat = 40
    private let READY_THRESHOLD: CGFloat = 90
    
    enum State {
        /// The state where the user has not pulled down.
        case none
        /// The state where the user has slightly pulled down but not enough to trigger a refresh.
        case pending
        /// The state where the user has pulled down sufficiently to be ready to trigger a refresh.
        case ready
        /// The state where the user has pulled down completely, and the refresh action is actively running.
        case loading
        
        var indicatorOpacity: CGFloat {
            switch self {
            case .none:
                return 0
            case .pending:
                return 0.2
            case .ready, .loading:
                return 1
            }
        }
    }
    
    private var previousScrollOffset: CGFloat = 0
    
    var scrollViewHeight: CGFloat = 0
    
    var scrollOffset: CGFloat = 0 {
        didSet {
            previousScrollOffset = oldValue
        }
    }
    
    var differentialOffset: CGFloat {
        scrollOffset - previousScrollOffset
    }
    
    var state: State = .none
    
    mutating func updateState(for scrollOffset: CGFloat) {
        self.scrollOffset = scrollOffset

        // If in pending or ready state and canceled before reaching ready
        if state == .pending || (state == .ready && scrollOffset <= 0) {
            state = .none
        }

        // If pulled to pending state where the refresh indicator is visible
        if state == .none && scrollOffset > PENDING_THRESHOLD {
            state = .pending
        }

        // If pulled to ready state confirming the refresh
        if state == .pending && scrollOffset > READY_THRESHOLD {
            state = .ready
        }

        // If in ready state and the view is released (detected by dy), start refresh loading
        if state == .ready
            && scrollOffset > READY_THRESHOLD
            && isDragEnd(dy: differentialOffset) {
            state = .loading
        }
    }

    mutating func reset() {
        state = .none
    }

    
    /// Considered as the user has released their touch and the scroll view is returning to its original state
    /// if the change in the scroll view's offset is significantly negative.
    func isDragEnd(dy: CGFloat) -> Bool {
        return differentialOffset < -10
    }
}

extension View {
    func asIndicator() -> AnyView {
        return AnyView(self)
    }
}

struct RefreshableView<Content: View>: View {
    private let GEOMETRY_HEIGHT: CGFloat = 15
    private let START_PENDING_OFFSET: CGFloat = 40
    private let START_READY_OFFSET: CGFloat = 90
    
    @Namespace private var namespace
    
    @State private var refreshable: Refreshable = .init()
    @State private var isRefreshing: Bool = false
    
    private let reverse: Bool
    @ViewBuilder private var content: () -> Content
    @ViewBuilder private var indicator: () -> AnyView?
    private var onRefresh: () async -> Void
    
    init(
        reverse: Bool = false,
        @ViewBuilder content: @escaping () -> Content,
        @ViewBuilder indicator: @escaping () -> AnyView? = { nil },
        onRefresh: @escaping () async -> Void
    ) {
        self.reverse = reverse
        self.content = content
        self.indicator = indicator
        self.onRefresh = onRefresh
    }
    
    var body: some View {
        ScrollView(.vertical) {
            content()
                .rotationEffect(.degrees(reverse ? 180 : 0))
                .scaleEffect(x: -1)
                .background(
                    GeometryReader { geometry in
                        DispatchQueue.main.async {
                            // Set scroll offset to 0.0 when scrolling up to maximum
                            let scrollOffset = reverse
                            ? -geometry.frame(in: .named(namespace)).maxY + refreshable.scrollViewHeight
                            : geometry.frame(in: .named(namespace)).minY
                            
                            refreshable.updateState(for: scrollOffset)
                            
                            // If in loading state, start the refresh action
                            if refreshable.state == .loading && !isRefreshing {
                                isRefreshing = true
                                Task {
                                    await onRefresh()
                                    DispatchQueue.main.async {
                                        refreshable.reset()
                                        isRefreshing = false
                                    }
                                }
                            }
                        }
                        
                        return Color.clear
                    }
                )
        }
        .coordinateSpace(name: namespace)
        .background(
            GeometryReader { geometry in
                DispatchQueue.main.async {
                    if reverse {
                        refreshable.scrollViewHeight = geometry.size.height
                    }
                }
                return Color.clear
            }
        )
        .overlay {
            VStack {
                Group {
                    if let customIndicator = indicator() {
                        customIndicator
                    } else {
                        basicIndicator
                    }
                }
                .opacity(refreshable.state.indicatorOpacity)
                .offset(y: refreshable.scrollOffset * 0.3)
                .animation(.linear, value: refreshable.state)
                .padding(.top, 10)
                Spacer()
            }
            .rotationEffect(.degrees(reverse ? 180 : 0))
            .scaleEffect(x: -1)
        }
        .rotationEffect(.degrees(reverse ? 180 : 0))
        .scaleEffect(x: -1)
    }
    
    private var basicIndicator: some View {
        Image(systemName: "arrow.up")
            .padding(5)
            .background(.gray02)
            .cornerRadius(10)
            .foregroundStyle(.black)
    }
}

 

 

중요한점은 1. reverse, 2. state 입니다.

reverse는 간단하게, 위로 무한스크롤인지, 아래로 무한스크롤인지 입니다. reverse = true이면 위로 무한스크롤되도록 스크롤뷰를 뒤집어줍니다.

(.scaleEffect(y: ..)에 reverse 변수 적용시 애니메이션 오류가 있어서 rotationEffect로 변경했습니다.)

 

State는 이렇게 4가지 입니다.

- .none: 당기지 않은 상태

- .pending: 당기기 시작후 인디케이터가 보이는 상태 (여기서 drag end시 로드 X)

- .ready: 더욱 당겨서 리로드가 확정된 상태

- .loading: 손을 떼고 scroll view가 원래대로 돌아가면서 로드가 이루어지고 있는 상태

물론 state는 RefreshableView 내부적으로 관리되므로 외부에서 신경 쓸 필요는 없습니다!!

 

이제 적용해보겠습니다!

struct ChatScrollTest: View {
    struct Message: Hashable {
        let id: UUID = .init()
        let text: String
    }
    
    @State var text: String = ""
    @State var messages: [Message] = (0..<50).map { Message(text: String($0)) }
    
    func addMore() async {
        // try? await Task.sleep(nanoseconds: 1_000_000_000)
        DispatchQueue.main.async {
            if let lastMessage = messages.last?.text {
                let newMessages = (Int(lastMessage)!...Int(lastMessage)!+30).map { i in
                    Message(text: String(i))
                }
                messages.append(contentsOf: newMessages)
            }
        }
    }
    
    var body: some View {
        VStack {
            // chat layer
            RefreshableView(reverse: true) { // ✅
                LazyVStack {
                    ForEach(messages.reversed(), id: \.self) { message in
                        Text("\(message.text)")
                            .id(message.id)
                    }
                }
            } indicator: {
                Image (systemName: "arrow.up")
                    .foregroundColor(.white)
                    .padding(5)
                    .background(.black)
                    .cornerRadius(15)
                    .asIndicator()
            } onRefresh: {
                await addMore()
            }
            .background(Color.gray02)
                
                // text field layer
                HStack { ... }
            }
        }
    }
}

 

로드 느린 경우 / 로드 빠른 경우

 

이로써 로드 속도에 상관없이 로드중이라는 것을 사용자에게 적절히 피드백해줄 수 있는 뷰를 완성했습니다! 🎉 🎉

 

 


구현을 시작할땐 이게 이렇게까지 고민할 문제가 될줄 몰랐지만...해결해내서 좋습니다 ㅎㅎ

이렇게 사소한 UX 디테일을 잡아가야 완성도 있는 앱이 되겠죠 !!! 

읽어주셔서 감사합니다 ! 

 

🔽 실제 적용기

더보기

실제 만들고 있는 앱에서 차근차근 적용하며 개선시킨 모습입니다.

1차시도: RefreshableView(reverse: false) + scroll proxy

맨 위로 갔다가 다시 내려가는 움직임도 거슬리고, 무엇보다 정확한 위치로 보내기가 잘 안되었습니다..

 

2차시도: 거꾸로 인피니티 스크롤

영상을 찍을때는 로컬서버 + 20개정도 fetch라 상당히 빨랐지만, 실제 서버에 연결하면 사알짝 느린 문제가 있었습니다.

또 단체채팅방을 기획하고 있어서 fetch량도 늘릴 예정이었습니다.

3차시도: RefreshableView(reverse: true)

깔끔한 것 같습니다.

 

 

 

ref.

Beyond scroll views - WWDC23

SwiftUI 2.0 Pull To Refresh - Custom Pull To Refresh Control - SwiftUI 2.0 Tutorials

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
글 보관함