DADAHAE's Log
비싼 장난감 가지고 노는 중 (❁´▽`❁)*✲゚*
[SwiftUI] SwiftUI에서 무한스크롤(Infinite Scroll) 구현하기

GitSpace라고 지금 팀원들과 개발 중인 서비스가 있는데, 해당 앱에서 무한 스크롤 기능이 필요했다. 

글은 2월 27일에 작성했지만, 블로그용으로 다시 올린다. 

 

 

LazyVStack으로 무한 스크롤 구현하기

1️⃣ 무한 스크롤이 어디에 필요한가요?

GitSpace는 깃허브 스타 레포를 관리하고 해당 레포의 컨트리뷰터와 네트워킹을 할 수 있는 서비스이다.

그리고 내가 담당한 부분 중 하나인 StarredView는 GitHub로 로그인한 사용자의 Starred Repository를 보여준다. GitHub REST API를 사용하는데, 모든 레포를 한번에 불러올 수 없다. paging으로 한번에 불러올 수 있는 개수가 정해져있고, 각 묶음을 하나의 page라고 부른다. 이때 page는 여러장이 될 수 있으며 페이지 번호로 다음 page를 요청할 수 있다.

그러나 지금은 단순히 기본 30개만 불러오도록 만들어져있다. 모든 Starred Repository를 보여주려면... paging으로 다음 리스트를 보여주거나, 무한 스크롤을 구현해야 한다.

 

 

2️⃣ 구현 방법

모바일의 특성상 스크롤이 더 편하다는 점을 고려하여 무한스크롤을 구현하기로 했다.
내가 UIKit에서 무한스크롤을 구현했을 때에는, 현재 스크롤의 위치를 스크린에서 계산하여 아래쪽에 위치하면 새로운 request를 보내는 형식으로 구현하였다. 
SwiftUI에서도 비슷하게 구현할 수 있을 것이라 생각했고, 구글링을 해보니 쉬운 방법과 어려운 방법 2가지로 나뉘어졌다.

 

  1. Combine으로 구현하기
    • 내가 찾아본 방법 중 어려운 방법이었다. PageStatus 구조체를 만들어서 onReceive하고... 이런 방식이었다. 아직 Combine에 대해 공부를 제대로 해본적이 없어서 그냥 겉햝기로 이해하고 넘어갔다.
    • Combine을 써서 꼭 구현해야 할까? 라는 생각이 계속 들어서 더 쉬운 방법을 찾아보기로 했다.
  2. LazyVStack으로 구현하기
    • UIKit에서 스크롤 위치로 무한 스크롤을 구현한 경험이 있기 때문에, SwiftUI에서도 단순하게 접근할 수 있을 거라 생각했다. 역시나 좀 더 쉬운 접근법이 있었다. LazyVStack으로 List의 마지막 요소가 뷰에 올라오면 그때 다음 page의 request를 보내는 방법이었다.
    • 일반 VStack은 내부의 요소가 뷰에 보이지 않아도 모두 메모리에 로드되지만, LazyVStack은 뷰에 보여질 때 메모리에 로드가 된다. 그래서 사용자가 스크롤을 하면 그제서야 List의 row들이 메모리에 올라간다.
    • 이를 이용하여 사용자가 자연스럽게 마지막 요소로 스크롤할 때 다음 page를 호출하게 한다.

 

3️⃣ 무한 스크롤을 구현해보자.

  1. 레포지토리를 불러오는 반복문 내부를 `LazyVStack`으로 감싼다.
    • 반복문으로 불러오는 row들은 실제 뷰에 보여질 때 메모리에 로드된다.
    • 그러므로 사용자가 스크롤을 내려야만 다음 레포들이 보여지게 된다. 
      • `onAppear`로 마지막 요소가 화면에 보이는지 확인하도록 하자!
  2. 화면에 마지막 아이템이 메모리에 로드되면(`onAppear`), 다음 page를 호출한다.
    • page값을 저장하는 변수를 StarredView에 만들어두고, 다음 page를 호출할 때 1씩 올려준다.
    • 단, 다음 page를 호출하고 있는 중(isLoading)이라면 중복하여 page를 호출하지 않도록 해준다.
      • `onAppear`은 뷰가 나타날 때 한번 수행되므로(메모리에 로드되는 것과는 다름), row가 한번 보이면 그때 수행된다.
      • 그러므로 아래의 코드에는 `isLoading` 변수를 추가하지 않았다.
  3. refresh를 할 때는 page 변수를 초기화해준다.
    • 새로 로드하는 것이기 때문에 1로 초기화하고 레포지토리를 호출한다.

 

아래의 코드는 실제 GitSpace에 구현한 무한 스크롤 코드이다. 무한스크롤과 관련된 코드만 작성된 상태이다.

struct StarredView: View {
	...
    @EnvironmentObject var repositoryViewModel: RepositoryViewModel
    @State private var currentPage: Int = 1
    
    var body: some View {
    ...
    ScrollView {
        ...
        // repositoryViewModel.repositories는 배열이다.
        ForEach(repositoryViewModel.repositories) { repository in
            // 반복문 내부에 LazyVStack을 심어준다.
            // 반복되는 각 row들을 LazyVStack으로 한번 감싸면서 
            // 해당 row가 화면에 보일때만 메모리에 로드된다.
            LazyVStack { 
                RepositoryRow(...)
                    .onAppear {
                        if repository == repositoryViewModel.repositories.last {
                            currentPage += 1
                            repositoryViewModel.repositories += /* currentPage를 매개변수로 하는 Starred Repository Request 함수 호출, currentPage에 해당하는 repository 리턴 */
                        }
                    }
                }
            }
        }
        .refreshable {
            currentPage = 1
            repositoryViewModel.repositories = /* currentPage를 매개변수로 하는 Starred Repository Request 함수 호출, currentPage에 해당하는 repository 리턴 */
        }
	}
}

 

 

 

꼭 맨 마지막 row를 트리거로 사용하지 않아도 된다!

위 코드에는 마지막 row가 뷰에 나타날 때 다음 page를 호출한다. 

 

그러나...

 

생각보다 다음 페이지를 로드하는데 시간이 걸린다.

  • 배열의 마지막 요소가 로드될 때 다음 page를 불러오니까 그때까지 1초 미만의 기다리는 시간이 필요했다. 그리고 마지막 요소에 가면 스크롤이 멈추는데, 다음 page가 로드되었는지 바로 확인이 안되었다. 스크롤이 멈추니까 새로운 요소가 들어와도 스크롤 길이에 바로 반영이 안되었다.
  • 결국 사용자가 한번 더 스크롤을 해줘야 다음 요소를 볼 수 있었다. 나는 이 지점이 너무 거슬렸고..! 다른 방법을 생각해보았다.

 

마지막 요소보다 조금 더 앞의 row를 기준으로 다음 페이지를 불러오면 더욱 자연스러운 스크롤이 될 것 같았다.

  • 사용자는 마지막 요소가 보이기 전에 스크롤을 계속 할거고, 마지막 페이지에 닿기 전에 다음 page가 추가되어서 사용자는 기다리지 않고 스크롤이 가능해질 것이다!

 

그러면 마지막이 아닌 그 앞 row를 어떻게 구분할 수 있을까?

  • 배열의 index 값을 알면 마지막 요소보다 앞의 row를 구분할 수 있다.
  • `Array(zip(repo.indices, repo))`를 사용하여 index값을 가져와보자.
struct StarredView: View {
	...
	@EnvironmentObject var repositoryViewModel: RepositoryViewModel
	@State private var currentPage: Int = 1

	var body: some View {
		...
		ScrollView {
			...
			// index와 repository를 모두 알아야 하기 때문에
			// zip을 사용하여 2개의 값을 하나로 묶어주자.
			ForEach(Array(zip(repositoryViewModel.repositories.indices, repositoryViewModel.repositories)), id:\.0) { index, repository in
				LazyVStack { 
					RepositoryRow(...)
						.onAppear {
							if index % 30 == 27 {
								currentPage += 1
								repositoryViewModel.repositories += /* currentPage를 매개변수로 하는 Starred Repository Request 함수 호출, currentPage에 해당하는 repository 리턴 */
							}
						}
				}
			}
		}
		.refreshable {
			...
		}
	}
}

 

 

 

구현 중 발견된 에러

  1. selected tag로 필터링을 하고 하단으로 스크롤을 하면 레포 리스트가 사라진다.
    • 실기기로 테스트하니 문제가 발견되지 않았다. 
    • 시뮬에서 생긴 오류인걸로!
  2. 무한스크롤을 하고 레포 디테일 페이지게 갔다가 오면 새롭게 `onAppear`이 되어있다. 
    • viewDidLoad 기능을 하는 익스텐션을 만들어버리자.
    • `onAppear처럼` `.onViewDidLoad { ... }` 로 사용하면된다.
    • 이제 StarredView가 메모리에 로드될 때 딱 1번만 수행된다.
    • 그러므로 레포 디테일 뷰에 갔다가 돌아와도 request 함수를 호출하지 않는다!
struct StarredView: View {
	...
	@EnvironmentObject var repositoryViewModel: RepositoryViewModel
	@State private var currentPage: Int = 1

	var body: some View {
		...
		ScrollView {
			...
			ForEach(Array(zip(repositoryViewModel.repositories.indices, repositoryViewModel.repositories)), id:\.0) { index, repository in
				LazyVStack { 
					RepositoryRow(...)
						.onAppear {
							...
						}
					}
				}
			}
		}
		/* 직접 만든 viewDidLoad 수정자 */
		.onViewDidLoad {
			repositoryViewModel.repositories = /* request 함수 호출, currentPage를 매개변수로 함. */
		}
		.refreshable {
			...
		}
	}
}

 

extension View {
	// MARK: SwiftUI에서 ViewDidLoad를 구현하기 위한 Extension
    /// 뷰가 메모리에 로드될 경우 수행되게 하는 ViewDidLoad 함수.
    /// UIKit에는 viewDidLoad 함수가 있지만 SwiftUI에는 존재하지 않는다.
    /// 그러므로 onAppear을 사용하여 직접 viewDidLoad를 수정자로 구현한다.
    func onViewDidLoad(perform action: (() -> Void)? = nil) ->some View {
        self.modifier(ViewDidLoadModifier(action: action))
    }
}

 

// MARK: ViewDidLoad 수정자
/// 뷰가 한번 로드되었을 때를 감지하여 액션을 수행하게 하는 ViewDidLoad 수정자이다.
struct ViewDidLoadModifier: ViewModifier {
    @State private var viewDidLoad: Bool = false
    let action: (() -> Void)?

    func body(content: Content) -> some View {
        content
            .onAppear {
                if viewDidLoad == false {
                    viewDidLoad = true
                    action?()
                }
            }
    }
}

 

완성!

 


 

reference

 

  • Equatable을 채택하는 경우 구현해야하는 메서드
    : 구조체의 인스턴스끼리 비교하기 위해서는 Equatable을 채택하고 기본 메서드를 만들어줘야 한다.
    https://babbab2.tistory.com/148
  • Array(zip(repo.indices, repo)) 
    https://gyuios.tistory.com/168
  • SwiftUI의 viewDidLoad
    : 아직은 직접 만들어야 한다.
    - https://sarunw.com/posts/swiftui-viewdidload/?utm_content=anc-true 

 

 

 

  Comments,     Trackbacks