-
AsyncImage 구현하기(1)SwiftUI 2023. 6. 9. 16:25
iOS15에 추가된 AsyncImage가 추가되었습니다. 이제 URL만 입력해 주면 알아서 해당 위치의 AsyncImage에 url 위치의 이미지를 다운로드 해줍니다. 해당 AnyncImage는 SwiftUI 특성 상 화면에 사라지면 소멸되고 다시 화면에 표시되려면 재 생성해야 하는데 캐시 기능이 없는 AsyncImage는 네트워크에서 이미지를 다시 다운로드 해야하는 단점이 있습니다. 또한 iOS15 이전에서는 사용할 수 없습니다. 그래서 여기에서는 AsyncImage 정의를 이용해서 동일한 타입의 CachedAsyncImage 를 구현 해 보도록 하겠습니다.
먼저 AsyncImage의 아래와 같은 세개의 초기화 함수가 있습니다.
public init(url: URL?, scale: CGFloat = 1) where Content == Image public init<I, P>(url: URL?, scale: CGFloat = 1, @ViewBuilder content: @escaping (Image) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent<I, P>, I : View, P : View public init(url: URL?, scale: CGFloat = 1, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncImagePhase) -> Content)
AsyncImage(url:) 는 성공 시내부에 Image 뷰를 보여줍니다.
AsyncImage(url:content:placeholder) 내부에 다운로드가 완료되면 Image 뷰를 그르고 아니면 placehoder를 보여 줍니다.
AsyncImage(url:content)는 내부 AsyncImagePhase에 따라 처리할 사용자가 스스로 선택할 수 있게 합니다. 아 여기서 우리는 AsyncImage는 상태 값 AsnycImagePhase 에 따라 뷰를 변경하는구나를 알수 있습니다. 그럼 AsyncImagePhase에 대해서 알아봐야 겠죠.
public enum AsyncImagePhase { case empty case success(Image) case failure(Error) public var image: Image? { get } public var error: (Error)? { get } }
예 맞습니다. AsyncImagePhase 는 URL 요청에 따라 상태 값을 변경해 각각의 상황에 따라 해당 뷰를 처리하는 방식으로 동작하고 있습니다. 그럼 우선 CachedAsyncImage 도 이런 구조로 동작 할 수 있도록 틀을 잡아 보겠습니다.
public enum CachedAsyncImagePhase { case empty case success(Image) case failure(Error) public var image: Image? { if case let .success(image) = self { return image } return nil } public var error: (Error)? { if case let .failure(error) = self { return error } return nil } }
위와같이 CachedAsnycImage의 상태 전이를 표현할 CachedAsncyImagePhase 개체를 정의하고
struct CachedAsyncImage<Content>: View where Content: View{ @State private var phase: CachedAsyncImagePhase }
이렇게 추가해 주면 나중 네트워크 에서 데이터를 가져올 때, 실패 했을 때 에 따라 phase 값을 변경하면 뷰의 화면도 갱신될 겁니다.
이제 가장 기본이 되는 마지막 초기화 함수를 작성하고 해당 초기화 함수를 이용해 나머지 두 초기화 함수도 구현해 주겠습니다.
struct CachedAsyncImage<Content>: View where Content: View{ private var url: URL? private var scale: CGFloat private var transaction: Transaction private var content: (CachedAsyncImagePhase) -> Content @State private var phase: CachedAsyncImagePhase init(url: URL?, scale: CGFloat = 1.0, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (CachedAsyncImagePhase) -> Content) { self.url = url self.scale = scale self.transaction = transaction self.content = content self._phase = State(wrappedValue: .empty) } var body: some View { content(phase) } }
여기서 주의 깊게 봐야 할 부분은 상태 값 phase 의 초기화 부분입니다. 초기화 되지 않은 상태값은 언더바(_)pahse 에 State(wrappedValue: .empy)를 통해 직접 State 변수를 생성해줘야 합니다.
이제 init(url:URL?) 초기화 함수를 구현해 보고 이를 Preview를 통해 화면에 출력해 보죠.
init(url: URL?, scale: CGFloat = 1) where Content == Image { self.init(url: url) { phase in Image(systemName: "square.and.arrow.up") } }
init(url: URL?, scale: CGFloat) 함수는 위와 같이 self.init(url:scale:transaction:content)를 통해서 초기화 합니다. 위에서와 같이 content에서 Image 뷰를 표시하겠죠. 여기서는 Preview 상태를 보기 위해 square.and.arrow.up 이미지를 표시하도록 해 두었습니다.
struct CachedAsyncImage_Previews: PreviewProvider { static var previews: some View { CachedAsyncImage(url: nil) } }
그러면 아래와 같은 화면이 보여 집니다.
이제 나머지 초기화 함수를 아래와 같이 구현합니다.
init<I, P>(url: URL, scale: CGFloat = 1, @ViewBuilder content: @escaping (Image) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent<I, P>, I : View, P : View { self.init(url: url, scale: scale) { phase in if case .success(let image) = phase { content(image) } else { placeholder() } } }
위의 함수를 보면 phase 의 상태가 성공하면 상태에서 image를 가져와 해당 이미지를 처리하는 content(image)를 호출하고 실패하거나 이미지가 없으면 placeholder() 를 사용하도록 구현합니다.
벌써 반이나 왔습니다. 이제 URL을 이용해서 해당 주소에 image를 받아오는 부분만 처리하면 되겠죠.
이제 뷰에 시작 시 URL 주소로 이미지 요청을 위해 iOS15.0 이상에서는 .task 한정자를 이용하겠습니다. 이전 버전은 .onAppear에 해당 부분을 구현하는 걸로 마무리 합니다.
var body: some View { if #available(iOS 15.0, *) { content(phase) .task { guard let url = self.url else { return } let request = URLRequest(url: url) do { //async await를 이용하여 구현 let (data, response) = try await URLSession.shared.data(for: request) let httpResponse = response as! HTTPURLResponse if httpResponse.statusCode < 200 || httpResponse.statusCode > 300 { self.phase = .failure(CachedAsyncImageError.httpResponseError(httpResponse.statusCode)) print("http response error: \(httpResponse.statusCode)") } guard let uiImage = UIImage(data: data) else { self.phase = .failure(CachedAsyncImageError.imageCreatedFail) print("image create fail") return } withAnimation(transaction.animation) { self.phase = .success(Image(uiImage: uiImage)) } } catch { self.phase = .failure(error) print("url request failed: \(error)") } } } else {
아래 부분은 15.0 이후 버전에 동작하는 코드입니다.
} else { content(phase) .onAppear { guard let url = self.url else { return } let reqeust = URLRequest(url: url) URLSession.shared.dataTask(with: reqeust){(data, response, error) in DispatchQueue.main.async { if let error = error { self.phase = .failure(error) } let httpResponse = response as! HTTPURLResponse if httpResponse.statusCode < 200 || httpResponse.statusCode > 300 { self.phase = .failure(CachedAsyncImageError.httpResponseError(httpResponse.statusCode)) print("http response error: \(httpResponse.statusCode)") return } guard let uiImage = UIImage(data: data!) else { self.phase = .failure(CachedAsyncImageError.imageCreatedFail) print("image create fail") return } withAnimation(transaction.animation) { self.phase = .success(Image(uiImage: uiImage)) } } }.resume() }
이제 동작을 확인했으니 URLRequest 부분과 상태관리를 CachedAsyncImageViewModel로 변경해 보겠습니다.
class CachedAsyncImageViewModel: ObservableObject { @Published var phase: CachedAsyncImagePhase = .empty
이제 URL 요청에 따른 상태 관리는 CachedAsyncImageViewModel을 통해서 이루어지고 CachedAsyncImageView는 상태값이 변경 될 때마다 자동으로 뷰를 재 생성합니다.
struct CachedAsyncImage<Content>: View where Content: View{ @ObservedObject private var viewModel = CachedAsyncImageViewModel()
이제 iOS15 이상 버전의 fetcth 함수와 이전 버전의 함수를 CachedAsyncImageViewModel로 이동시키는 데, 이전 버전의 함수는 Combine을 사용해 변경해 보겠습니다.
class CachedAsyncImageViewModel: ObservableObject { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) func fetch(url: URL?) async { guard let url = url else { phase = .failure(CachedAsyncImageError.urlError) return } let request = URLRequest(url: url) do { let (data, response) = try await URLSession.shared.data(for: request) let httpResponse = response as! HTTPURLResponse if httpResponse.statusCode < 200 || httpResponse.statusCode > 300 { phase = .failure(CachedAsyncImageError.httpResponseError(httpResponse.statusCode)) return } guard let uiImage = UIImage(data: data) else { phase = .failure(CachedAsyncImageError.imageCreatedFail) return } phase = .success(Image(uiImage: uiImage)) } catch { phase = .failure(error) } }
func fetchImage(url: URL?) { guard let url = url else { phase = .failure(CachedAsyncImageError.urlError) return } cancelable = URLSession.shared.dataTaskPublisher(for: url) .tryMap { data, response in //HTTP 에러 처리 completed 가 .failure를 전달하도록 변경 let httpResponse = response as! HTTPURLResponse if httpResponse.statusCode < 200 || httpResponse.statusCode > 300 { throw CachedAsyncImageError.httpResponseError(httpResponse.statusCode) } //response 에 관심이 없으면 제거 return (data, response) } .receive(on: DispatchQueue.main) .sink { completon in if case let .failure(error) = completon { self.phase = .failure(error) } } receiveValue: { data, response in guard let uiImage = UIImage(data: data) else { self.phase = .failure(CachedAsyncImageError.imageCreatedFail) return } self.phase = .success(Image(uiImage: uiImage)) } }
Combine으로 변경한 내용 중에 중요한 점은 상태 값 즉 퍼플리쉬 될 phase의 값의 변경은 메인 스레드에서해야 한다는 점입니다. 따라서 여기서 .receive(on: DispatchQueue.main)을 통해서 해당 전파가 메인 스레드에서 동작하도록 변경합니다.
이제 CachedAsyncImage에 해당 코드를 추가 합니다.
var body: some View { if #available(iOS 15.0, *) { content(viewModel.phase) .task { await viewModel.fetch(url: url) } } else { content(viewModel.phase) .onAppear { viewModel.fetchImage(url: url) } } }
캐시 관련 내용은 다음에 작성하도록 하겠습니다.
소스는 github 에서 다운받으 실 수 있습니다.
'SwiftUI' 카테고리의 다른 글
LibraryContentProvider 로 XCode 라이브러리에 뷰 추가하기 (0) 2023.06.10 AsyncImage 구현하기(2) (0) 2023.06.09 Core Buletooth 사용하기 (0) 2023.06.06 SwiftUI ScrollView 와 ForEach 로 구현한 영화 포스트 리스트 (0) 2020.08.25 SwiftUI LongPressGesture 를 사용한 Fingerprint Button (0) 2020.08.25