Alamofire with RefreshControl, InfinityScroll in SwiftUI
Updated:
📌 Main Features
-
Swift UI 에서 Alamofire 을 통한 API 통신
-
사용 API: RandomUSer.me - https://randomuser.me/documentation
-
Combine 을 이용해 비동기 통신 처리
-
이미지 라이브러리: URLImage
-
Infinite Scroll 무한 스크롤 기능 구현
-
RefreshControl 당겨서 새로고침 기능 구현
👉 Swift Package manager
🔶 URLImage
https://github.com/dmytro-anokhin/url-image#installation
🔶 Alamofire
https://github.com/Alamofire/Alamofire.git
🔶 Introspect
https://github.com/siteline/SwiftUI-Introspect
🔷 Model
// MARK: - MODEL
struct RandomUser: Codable, Identifiable {
var id = UUID()
var name: Name
var photo: Photo
// Jaon 에서 picture 인데 parsing 할때 photo 로 이름 을 바꿔 줌: CodingKey
private enum CodingKeys: String, CodingKey {
case name = "name"
case photo = "picture"
}
// preview 사용을 위한 dummy data 생성
static func getDummy() -> Self {
print(#fileID, #function, #line, "")
return RandomUser(name: Name.getDummy(), photo: Photo.getDummy())
}
// randomUser 의 profileImage 를 가져오기
var profileImageUrl : URL {
get {
URL(string: photo.medium)!
}
}
}
struct Name: Codable, CustomStringConvertible {
var title: String
var first: String
var last: String
// title, frist, last name 이 합쳐진 형식의 fullName 생성
var fullName: String {
return "[\(title)]. \(first) \(last)"
}
static func getDummy() -> Self {
print(#fileID, #function, #line, "")
return Name(title: "Mr", first: "Jacob", last: "Ko")
}
}
struct Photo: Codable {
var large: String
var medium: String
var thumbnail: String
static func getDummy() -> Self {
print(#fileID, #function, #line, "")
return Photo(large: "https://randomuser.me/api/portraits/men/87.jpg", medium: "https://randomuser.me/api/portraits/men/87.jpg", thumbnail: "https://randomuser.me/api/portraits/men/87.jpg")
}
}
struct Info: Codable {
var seed: String
var resultsCount: Int
var page: Int
var version: String
private enum CodingKeys: String, CodingKey {
case seed = "seed"
case resultsCount = "results"
case page = "page"
case version = "version"
}
}
struct RandomUserResponse: Codable, CustomStringConvertible {
var results: [RandomUser]
var info: Info
// description 생성
var description: String {
return "results.count: \(results.count) / info: \(info.seed)"
}
}
🔷 Part 1.RandomUser API 호출 하기
import Foundation
import Combine
import Alamofire
// MARK: - VIEWMODEL
class RandomUserViewModel: ObservableObject {
// MARK: - Properties
// 나중에 메모리에서 날리기 위해서 subscription 생성
var subscription = Set<AnyCancellable>()
// randomUsers 빈 배열 생성 - 받아온 데이터 저장 공간
@Published var randomUsers = [RandomUser]()
// 호출할 API 주소
var baseUrl = "https://randomuser.me/api/?results=100"
// ViewModel 이 생성이 될때 API 를 fetch 하게 함
init() {
// code 자동 완성
print(#fileID, #function, #line, "")
fetchRandomUser()
}
// MARK: - FUNCTION
func fetchRandomUser() {
print(#fileID, #function, #line, "")
AF.request(baseUrl)
.publishDecodable(type: RandomUserResponse.self)
// combine 에서 옵셔널을 제거 : compatMap 을 사용해서 optional 일 경우에 값이 있는 경우에것만 값으로 가져옴 -> unwrapping 이 자동으로 됨
.compactMap{ $0.value }
// RandomUserResponse 에서 그안에 results 만 가져오게 map 하기
.map { $0.results }
// sink 를 해서 구독을 해줌
.sink { completion in
print("데이터 가져오기 성공")
} receiveValue: { (receivedValue: [RandomUser]) in
print("받은 값 : \(receivedValue.count)")
self.randomUsers = receivedValue
}
// 구독이 완료되면 메모리에서 지워줌
.store(in: &subscription)
}
}
🔶 UI
// in ContentView.swift
import SwiftUI
struct ContentView: View {
// MARK: - PROPERTY
@ObservedObject var randomUserViewModel = RandomUserViewModel()
// MARK: - BODY
var body: some View {
List(randomUserViewModel.randomUsers) { aRandomUser in
RandomUserRowView(aRandomUser)
}
}
}
// in RandomUserRowView.swift
import SwiftUI
struct RandomUserRowView: View {
// MARK: - PROPERTY
var randomUser : RandomUser
init(_ randomUser: RandomUser) {
self.randomUser = randomUser
}
// MARK: - BODY
var body: some View {
HStack {
ProfileImageView(imageUrl: randomUser.profileImageUrl)
Text(randomUser.name.fullName)
.fontWeight(.heavy)
.font(.title)
.lineLimit(2)
.minimumScaleFactor(0.5)
} //: HSTACK
.frame(minWidth:0, maxWidth: .infinity, minHeight: 0, maxHeight: 50 , alignment: .leading)
}
}
// MARK: - PREVIEW
struct RandomUserRowView_Previews: PreviewProvider {
static var previews: some View {
RandomUserRowView(RandomUser.getDummy())
}
}
import SwiftUI
import URLImage
struct ProfileImageView: View {
// MARK: - PROPERTY
var imageUrl: URL
// MARK: - BODY
var body: some View {
URLImage(imageUrl) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50, height: 60)
.clipShape(Circle())
.overlay(Circle().stroke(Color.yellow, lineWidth: 2))
}
}
}
// MARK: - PREVIEW
struct ProfileImageView_Previews: PreviewProvider {
static var previews: some View {
let url = (URL(string: "https://randomuser.me/api/portraits/women/12.jpg")!)
ProfileImageView(imageUrl: url)
}
}
🔷 Part 2.RefreshControl (IntroSpect)
iOS 15 부터는 SwiftUI 자체 내에서 refreshable() modifier 를 지원합니다 - https://www.hackingwithswift.com/quick-start/swiftui/how-to-enable-pull-to-refresh
iOS 15 이전버전도 지원하기 위해서 라이브 설치가 필요한데, SwiftUI-Introspect 를 사용해서 RefreshControl 을 사용할 수 있습니다 - https://github.com/siteline/SwiftUI-Introspect
- UIViewController 를 SwiftUI 에서 사용하려면, Hosting View 를 사용해야 합니다
🔶 UIKit 의 refresh action 추가
- refresh 기능을 추가하고 action 기능을 추가하기 위해서 RefreshControlHelper 라는 class 를 생성해서 @objc 의 selector 를 이용해 refresh 동작시 action 기능을 추가합니다
// ContentView.swift
import SwiftUI
import UIKit
import Introspect
struct ContentView: View {
// MARK: - PROPERTY
@ObservedObject var randomUserViewModel = RandomUserViewModel()
let refreshControlHelper = RefreshControlHelper()
// MARK: - BODY
var body: some View {
List(randomUserViewModel.randomUsers) { aRandomUser in
RandomUserRowView(aRandomUser)
}
.listStyle(.plain)
// MARK: - Introspect 설정
.introspectTableView { tableView in
let myRefresh = UIRefreshControl()
refreshControlHelper.refreshControl = myRefresh
refreshControlHelper.parentContentView = self
myRefresh.addTarget(refreshControlHelper, action: #selector(RefreshControlHelper.didRefresh), for: .valueChanged)
tableView.refreshControl = myRefresh
}
}
}
// MARK: - PREVIEW
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
// MARK: - RefreshControl 에 action 추가하기
class RefreshControlHelper {
// Properties
var parentContentView: ContentView?
var refreshControl: UIRefreshControl?
@objc func didRefresh() {
print(#fileID, #function, #line, "")
guard let parentContentView = parentContentView,
let refreshControl = refreshControl else {
print("parentContentView, refreshControl 가 nil 입니다")
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
print("리프레시가 되었습니다")
refreshControl.endRefreshing() // refreshing 이 끝났다고 설정 하기
}
}
}
🔶 새로고침한 데이터 refresh action 에 추가
-
방법 1: 직접 viewModel 에 접근해서 fetch data method 실행 하기
-
방법 2 : ViewModel 에 직접 접근해서 control 하는것이 아니고, combine 을 통해서 refresh action 을 PassthroughSubject 를 생성해서 그것을 구독해서 fresh action 을 만들수 있습니다
(위 방법의 장점은 view 에서 직접 viewModel 로 접근하지 않기 때문에 fetchRandomUser()
함수를 ViewModel 안에서만 사용할 수 있게 fileprivate 할 수 있습니다)
// ContentView.swfit
// MARK: - api 새로고침 해서 새로운 정보 가져오기
// 방법 1: 직접 viewModel 접근해서 fetch data 실행하기
// parentContentView 가 ContentView 이니까 거기 viewModel 에 접근해서 fetch 데이터를 실행하기
// parentContentView.randomUserViewModel.fetchRandomUser()
// 방법 2: Combine 을 사용해서 action 을 보내서 viewModel 쪽에서 구독해서 event 를 처리하는 방법
// refreshActionSubject 에 event 보내줌 ㅛ
parentContentView.randomUserViewModel.refreshActionSubject.send()
refreshControl.endRefreshing() // refreshing 이 끝났다고 설정 하기
// MARK: - Helper Methods
extension ContentView {
fileprivate func configureRefreshControl(_ tableView: UITableView) {
print(#fileID, #function, #line, "")
let myRefresh = UIRefreshControl()
myRefresh.tintColor = #colorLiteral(red: 1, green: 0.6865338683, blue: 0.007479909807, alpha: 1) // Refresh color 변경
refreshControlHelper.refreshControl = myRefresh
refreshControlHelper.parentContentView = self
myRefresh.addTarget(refreshControlHelper, action: #selector(RefreshControlHelper.didRefresh), for: .valueChanged)
tableView.refreshControl = myRefresh
}
}
// RandomUserViewModel.swift
import Foundation
import Combine
import Alamofire
// MARK: - VIEWMODEL
class RandomUserViewModel: ObservableObject {
// MARK: - Properties
// 나중에 메모리에서 날리기 위해서 subscription 생성
var subscription = Set<AnyCancellable>()
// randomUsers 빈 배열 생성 - 받아온 데이터 저장 공간
@Published var randomUsers = [RandomUser]()
// refresh action 을 위한 PassthroughSubject subject 생성 - 단방향으로 이벤트를 한번만 보내기
var refreshActionSubject = PassthroughSubject<(), Never>()
// 호출할 API 주소
var baseUrl = "https://randomuser.me/api/?results=100"
// ViewModel 이 생성이 될때 API 를 fetch 하게 함
init() {
// code 자동 완성
print(#fileID, #function, #line, "")
fetchRandomUser()
// refreshActionSubject 구독하기
refreshActionSubject.sink{ [weak self] _ in
guard let self = self else { return }
print("RandomUserViewmodel 에 init 에 refreshActionSubject 가 호출 되었음")
self.fetchRandomUser()
}.store(in: &subscription)
}
// MARK: - FUNCTION
// combine 형태로
fileprivate func fetchRandomUser() {
print(#fileID, #function, #line, "")
AF.request(baseUrl)
.publishDecodable(type: RandomUserResponse.self)
// combine 에서 옵셔널을 제거 : compatMap 을 사용해서 optional 일 경우에 값이 있는 경우에것만 값으로 가져옴 -> unwrapping 이 자동으로 됨
.compactMap{ $0.value }
// RandomUserResponse 에서 그안에 results 만 가져오게 map 하기
.map { $0.results }
// sink 를 해서 구독을 해줌
.sink { completion in
print("데이터 가져오기 성공")
} receiveValue: { (receivedValue: [RandomUser]) in
print("받은 값 : \(receivedValue.count)")
self.randomUsers = receivedValue
}
// 구독이 완료되면 메모리에서 지워줌
.store(in: &subscription)
}
}
🔷 Part 3.Infinite Scroll (Pagination)
RandomUser api Pagenation
🔶 Router 생성
기존의 baseUrl 과 다른 api 호출 url 이기 때문에 따로 만들어 Router 를 만들어서 url 을 관리합니다
Alamofire Routing Requests - https://github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md#routing-requests
// RandomUserRouter.swift
import Foundation
import Alamofire
// https://randomuser.me/api/?page=3&results=10&seed=abc
let BASE_URL = "https://randomuser.me/api/"
enum RandomUserRouter: URLRequestConvertible {
case getUsers(page: Int = 1, results: Int = 20)
var baseURL: URL {
return URL(string: BASE_URL)!
}
var endPoint: String {
switch self {
case .getUsers:
return ""
default:
return ""
}
}
var method: HTTPMethod {
switch self {
case .getUsers:
return .get
default: return .get
}
}
var parameters: Parameters {
switch self {
case .getUsers(let page , let results):
var params = Parameters()
params["page"] = page
params["results"] = results
params["seed"] = "abc"
return params
default:
return Parameters()
}
}
func asURLRequest() throws -> URLRequest {
let url = baseURL.appendingPathComponent(endPoint)
var request = URLRequest(url: url)
request.method = method
switch self {
case .getUsers:
request = try URLEncoding.default.encode(request, with: parameters)
default:
return request
}
return request
}
}
// in RandomUser.swift
struct Info: Codable, CustomStringConvertible {
var seed: String
var resultsCount: Int
var page: Int
var version: String
private enum CodingKeys: String, CodingKey {
case seed = "seed"
case resultsCount = "results"
case page = "page"
case version = "version"
}
var infoDescription: String {
return "seed: \(seed) / resultCount: \(resultsCount) / page : \(page)"
}
}
// RandomUserViewModel.swift
// MARK: - Properties
@Published var pageInfo: Info? {
didSet {
print("pageInfo: \(pageInfo)")
}
}
// MARK: - FUNCTION
// combine 형태로
fileprivate func fetchRandomUser() {
print(#fileID, #function, #line, "")
AF.request(RandomUserRouter.getUsers())
.publishDecodable(type: RandomUserResponse.self)
// combine 에서 옵셔널을 제거 : compatMap 을 사용해서 optional 일 경우에 값이 있는 경우에것만 값으로 가져옴 -> unwrapping 이 자동으로 됨
.compactMap{ $0.value }
// sink 를 해서 구독을 해줌
.sink { completion in
print("데이터 가져오기 성공")
} receiveValue: { receivedValue in
print("받은 값 : \(receivedValue.results.count)")
self.randomUsers = receivedValue.results
self.pageInfo = receivedValue.info
}
// 구독이 완료되면 메모리에서 지워줌
.store(in: &subscription)
}
}
🔶 다음 페이지 호출하기
불러온 데이터에서 리스트가 마지막에 닿았을때, 다음 페이지를 호출하기
- 맨 list 마지막에 닿았다는 것을 알게 되는것은 RandomUser 에는 각각의 고유 ID 값이 있는데, 제일 마지막에 있는 ID 와 현재 ID 와 같으면 거기가 맨마지막이라고 설정할 수 있다 -> Equatable protocol 사용
// RandomUser.swift
struct RandomUser: Codable, Identifiable, Equatable {
var id = UUID()
var name: Name
var photo: Photo
// Jaon 에서 picture 인데 parsing 할때 photo 로 이름 을 바꿔 줌: CodingKey
private enum CodingKeys: String, CodingKey {
case name = "name"
case photo = "picture"
}
// preview 사용을 위한 dummy data 생성
static func getDummy() -> Self {
print(#fileID, #function, #line, "")
return RandomUser(name: Name.getDummy(), photo: Photo.getDummy())
}
// randomUser 의 profileImage 를 가져오기
var profileImageUrl : URL {
get {
URL(string: photo.medium)!
}
}
// 비교를 위한 Equatable protocol logic
// 첫번째, 두번째 값이 같다는 판단기준을 어떻게 할건지에 대해 작성하기
static func == (lhs: RandomUser, rhs: RandomUser) -> Bool {
return lhs.id == rhs.id
}
}
// ContentView.swift
// MARK: - BODY
var body: some View {
List(randomUserViewModel.randomUsers) { aRandomUser in
RandomUserRowView(aRandomUser)
.onAppear {
print("RandomUserRowView - onAppear() 호출됨")
// RandomUserRowView 가 나타 날때 마지막 id 와 현재 id 를 비교
if self.randomUserViewModel.randomUsers.last == aRandomUser {
print("마지막 리스트입니다")
// 마지막 부분일때 ffetchMoreActionSubject 에 event 전송
randomUserViewModel.fetchMoreActionSubject.send()
}
}
}
.listStyle(.plain)
// RandomUserViewModel.swift
import Foundation
import Combine
import Alamofire
// MARK: - VIEWMODEL
class RandomUserViewModel: ObservableObject {
// MARK: - Properties
// 나중에 메모리에서 날리기 위해서 subscription 생성
var subscription = Set<AnyCancellable>()
// randomUsers 빈 배열 생성 - 받아온 데이터 저장 공간
@Published var randomUsers = [RandomUser]()
@Published var pageInfo: Info? {
didSet {
print("pageInfo: \(pageInfo)")
}
}
// refresh action 을 위한 PassthroughSubject subject 생성 - 단방향으로 이벤트를 한번만 보내기
var refreshActionSubject = PassthroughSubject<(), Never>()
// list 바닥에 닿았을때 refresh action 실행 하고 그 action 에 fetchMore() 가 실행되게 함
var fetchMoreActionSubject = PassthroughSubject<(), Never>()
// 호출할 API 주소
var baseUrl = "https://randomuser.me/api/?results=100"
// ViewModel 이 생성이 될때 API 를 fetch 하게 함
init() {
// code 자동 완성
print(#fileID, #function, #line, "")
fetchRandomUser()
// refreshActionSubject 구독하기
refreshActionSubject.sink{ [weak self] _ in
guard let self = self else { return }
print("RandomUserViewmodel 에 init 에 refreshActionSubject 가 호출 되었음")
self.fetchRandomUser()
}.store(in: &subscription)
// fetchMoreActionSubject 구독하기
fetchMoreActionSubject.sink{ [weak self] _ in
guard let self = self else { return }
print("RandomUserViewmodel 에 init 에 refreshActionSubject 가 호출 되었음")
self.fetchMore()
}.store(in: &subscription)
}
// MARK: - FUNCTION
// fetch 데이터 가져오기
fileprivate func fetchRandomUser() {
print(#fileID, #function, #line, "")
AF.request(RandomUserRouter.getUsers())
.publishDecodable(type: RandomUserResponse.self)
// combine 에서 옵셔널을 제거 : compatMap 을 사용해서 optional 일 경우에 값이 있는 경우에것만 값으로 가져옴 -> unwrapping 이 자동으로 됨
.compactMap{ $0.value }
// sink 를 해서 구독을 해줌
.sink { completion in
print("데이터 가져오기 성공")
} receiveValue: { receivedValue in
print("받은 값 : \(receivedValue.results.count)")
self.randomUsers = receivedValue.results
self.pageInfo = receivedValue.info
}
// 구독이 완료되면 메모리에서 지워줌
.store(in: &subscription)
}
// 마지막에 닿았을때 추가로 데이터 가져오기
fileprivate func fetchMore() {
print(#fileID, #function, #line, "")
guard let currentPage = pageInfo?.page else {
print("페이지 정보가 없습니다")
return
}
// 현재 페이지 에서 +1 해서 다음페이지가 호출되게 함
let pageToLoad = currentPage + 1
AF.request(RandomUserRouter.getUsers(page: pageToLoad))
.publishDecodable(type: RandomUserResponse.self)
// combine 에서 옵셔널을 제거 : compatMap 을 사용해서 optional 일 경우에 값이 있는 경우에것만 값으로 가져옴 -> unwrapping 이 자동으로 됨
.compactMap{ $0.value }
// sink 를 해서 구독을 해줌
.sink { completion in
print("데이터 가져오기 성공")
} receiveValue: { receivedValue in
print("받은 값 : \(receivedValue.results.count)")
// 기존것에 계속 누적 시켜서 api 를 호출 시킴
self.randomUsers += receivedValue.results
self.pageInfo = receivedValue.info
}
// 구독이 완료되면 메모리에서 지워줌
.store(in: &subscription)
}
}
이러면 화면상으로 무한 스크롤 기능 구현 완료
- 버그가 있는데, 스크롤을 빠르게 하다보면 아직 데이터가 fetch 되지 도 않았는데 data 가 당겨져 와져서 page 가 중복이 되는 bug 가 발생 된다 (아래 그림에서 page 11 의 경우 버그 발생)
🔑 위와 같이 페이지 중복 버그를 고치기 위해서는 데이터가 로딩을 안하고 있을때만, 데이터를 가져오게끔 logic 작성
// RandomUserViewModel.swift
// fetchMoreActionSubject 구독하기
fetchMoreActionSubject.sink{ [weak self] _ in
guard let self = self else { return }
print("RandomUserViewmodel 에 init 에 refreshActionSubject 가 호출 되었음")
// loading 중이 아닐때만 fetchMore 가 실행되게 함
if !self.isLoading {
self.fetchMore()
}
}.store(in: &subscription)
}
// 로딩 시작이 안되된것을 알려줌
self.isLoading = true
// 현재 페이지 에서 +1 해서 다음페이지가 호출되게 함
let pageToLoad = currentPage + 1
AF.request(RandomUserRouter.getUsers(page: pageToLoad))
.publishDecodable(type: RandomUserResponse.self)
// combine 에서 옵셔널을 제거 : compatMap 을 사용해서 optional 일 경우에 값이 있는 경우에것만 값으로 가져옴 -> unwrapping 이 자동으로 됨
.compactMap{ $0.value }
// sink 를 해서 구독을 해줌
.sink { completion in
print("데이터 가져오기 성공")
// 데이터를 가져오면 로딩 false 로 변경
self.isLoading = false
- UI 에서 로딩중이라면, 리스트 마지막 부분에서
ProgressView()
가 나오게 호출
// ContentView.swift
// MARK: - Introspect 설정
.introspectTableView { tableView in
self.configureRefreshControl(tableView)
}
// 데이터 로딩 중이라면, 로딩바 나오게 작동시키기
if randomUserViewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Color.yellow))
}
}
🗃 Reference
취준생을 위한 스위프트UI 앱만들기 강좌 Alamofire Combine - SwiftUI 2.0 fundamental Tutorial (2021) - Alamofire - https://youtu.be/aMes-DVVJg4
Alamofire gitHub - https://github.com/Alamofire/Alamofire
ImageURL github - https://github.com/dmytro-anokhin/url-image
RandomUser API Documentation - https://randomuser.me/documentation
SwiftUI-Introspect github - https://github.com/siteline/SwiftUI-Introspect
Leave a comment