티스토리 뷰
원래 무한스크롤이라고 하면 맨 아래까지 스크롤하는 것을 트리거로 새로운 컨텐츠를 계속 로드해 화면에 붙여주는 형태를 말하는데요,
저는 이와 다르게 위의 끝까지 스크롤했을 때 위로 컨텐츠가 붙는 기능이 필요했습니다.. (이전 채팅 보기 기능)
이를 구현하기 위해 시도했던 다양한 방법들을 공유해보려고 합니다!
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 { ... }
}
}
}
}
훨씬 괜찮아졌네요!!
하지만 문제가 있었습니다.
로드속도가 느릴때는 잘 자동하지만 로드속도가 매우 빠를때는 적용이 되지 않았습니다.
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.
SwiftUI 2.0 Pull To Refresh - Custom Pull To Refresh Control - SwiftUI 2.0 Tutorials
'iOS > SwiftUI' 카테고리의 다른 글
[SwiftUI] LinkNavigator (2) - 탭바 만들기, 네비게이션바 커스텀 (2) | 2023.05.03 |
---|---|
[SwiftUI] LinkNavigator (1) - 기본 사용법, 초기 설정 (4) | 2023.05.02 |
[SwiftUI] NavigationStack으로 여러 종류의 뷰 Push하기 (0) | 2023.04.29 |
[iOS] 너무 커진 ViewModel 분리하기 (MVVM) (0) | 2023.04.28 |
[SwiftUI][Combine] 프로퍼티 래퍼 어노테이션 만들기(Property Wrapper Anotation) (0) | 2023.04.07 |
- Total
- Today
- Yesterday
- Swift Concurrency
- MVVM
- healthkit
- design pattern
- MVC
- SwiftUI
- 비동기/동기
- Flux
- coordinator pattern
- combine
- RX
- 코디네이터 패턴
- SWM
- programmers
- MVI
- Flutter
- Architecture Pattern
- ios
- 노션
- 소프트웨어마에스트로
- notion
- GetX
- swift
- 리액티브 프로그래밍
- 아키텍쳐 패턴
- reactive programming
- TestCode
- 프로그래머스
- Bloking/Non-bloking
- DocC
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |