서론
SwiftUI에서 원격에 저장되어 있는 이미지를 보여줘야 하는 경우가 있습니다. 그럴 경우 iOS 15 이상에서 추가된 AsyncImage를 사용하면 되지만 최소 요구조건이 iOS 15 이상이며 URLSession의 shared 인스턴스밖에 사용할 수 없습니다. 추가적인 Header를 통해 접근할 수 없으며 유저가 인증된 상태에서만 볼 수 있는 이미지를 표시해야하는 경우도 생길 수 있습니다. 그럴 경우엔 Custom으로 구현해야 하는데 이번 글에서는 AsyncImage의 기능과 인터페이스를 유사하게 구현해보고 추가적으로 이미지 로딩에 실패했을때 다른 View를 보여줄 수 있도록 해보겠습니다.
본론
기존 AsyncImage의 기본적인 생성 인터페이스는 아래와 같습니다.
init(url: URL?, scale: CGFloat)
init<I, P>(url: URL?, scale: CGFloat, content: (Image) -> I, placeholder: () -> P)
그리고 아래의 상태 값을 갖고 있습니다.
enum AsyncImagePhase {
/// No image is loaded.
case empty
/// An image succesfully loaded.
case success(Image)
/// An image failed to load with an error.
case failure(Error)
/// The loaded image, if any.
var image: Image? { getter }
/// The error that occurred when attempting to load an image, if any.
var error: Error? { getter }
}
AsyncImagePhase의 경우 그대로 가져다 쓰면 좋겠지만, iOS 15+ 부터 사용 가능한 제약 조건이 있으니 직접 구현해주도록 합니다. 이름이 중복될 수 있으니 저는 AsyncImagePhase -> NetworkImagePhase로 네이밍을 치환하도록 하겠습니다.
enum NetworkImagePhase {
/// No image is loaded.
case empty
/// An image succesfully loaded.
case success(Image)
/// An image failed to load with an error.
case failure(Error)
}
위 처럼 enum을 구현해줬다면 NetworkImage 컴포넌트에서 사용될 이미지를 가져올 객체를 구현해줍니다.
@MainActor
final class NetworkImageLoader: ObservableObject {
private let url: URL
private let session: Session
@Published var phase = NetworkImagePhase.empty
init(url: URL, session: Session = .shared) {
self.url = URL
self.session = session
}
func load() { ⋯ }
func cancel() { ⋯ }
}
간단하게 load, cancel 메소드를 구현해줄 예정인데, 여기서 이미지 주소의 인자를 URL 타입으로만 받고 있습니다. URL타입으로만 제공되어지면 괜찮겠지만 여러가지 옵션을 더 주도록 해볼까요? NetworkImageSource 라는 열거형을 추가해봅시다.
enum NetworkImageSource {
/// An image source in String type
case string(path: String)
/// An image source in URL type
case url(url: URL?)
}
처음에 구현했던 ImageLoader의 생성자와 변수를 아래와 같이 변경해줍니다.
@MainActor
final class NetworkImageLoader: ObservableObject {
private let source: NetworkImageSource
⋯
init(source: NetworkImageSource, session: Session = .shared) {
self.source = source
⋯
}
}
이제 여러 타입으로도 제공할 수 있는 생성자로 구현되었습니다. 이제 실제 load 메소드와 cancel 메소드를 URLSession.shared가 아닌 커스텀한 Session 클래스의 통신 메소드를 통해 접근하면 됩니다. 아래와 같이 구현합니다.
@MainActor
final class NetworkImageLoader: ObservableObject {
private var task: Task<Void, Error>? = nil
deinit {
Task {
await cancel()
}
}
func load() {
task = Task {
do {
let sessionRequest: SessionRequest
switch source {
case .string(let path):
guard let url = URL(string: path) else { throw SessionError.missingURL }
sessionRequest = try SessionRequest(url: url)
case .url(let url):
guard let url else { throw SessionError.missingURL }
sessionRequest = try SessionRequest(url: url)
}
let image = try await session.imageTask(for: sessionRequest)
phase = .success(image)
} catch {
phase = .failure(error)
}
}
}
func cancel() {
task?.cancel()
}
}
아주 간단한 형태의 ImageLoader 클래스가 만들어졌습니다! source의 타입에 따라 URL 객체를 생성하고, Session 클래스를 통해 Image 객체를 얻은 뒤 반환하는 코드입니다. 에러가 있다면 phase 값에 .failure 값이 할당되고 인자로 Error를 전달하게 됩니다. 이제 View를 구현해볼까요?
struct NetworkImage<Content>: View where Content: View {
@StateObject private var loader: NetworkImageLoader
@ViewBuilder private var content: (NetworkImagePhase) -> Content
init(source: NetworkImageSource, @ViewBuilder content: @escaping (NetworkImagePhase) -> Content) {
_loader = .init(wrappedValue: NetworkImageLoader(source: source))
self.content = content
}
var body: some View {
content(loader.phase)
.onAppear {
loader.load()
}
}
}
첫번째 인자로는 원격에 저장된 이미지의 주소를 받을 수 있는 source를 정의해주었고, 두번째 인자로는 NetworkImagePhase에 따른 View 출력에 대한 기본적인 생성자를 정의해주었습니다. 외부로부터 데이터를 받고 상태를 초기화해야하므로 생성자를 해당 문서처럼 정의합니다. 이렇게 구현하면 나중에 View내의 상태값이 변경되어도 loader는 단 1회만 생성됩니다. 위처럼 구현했으면 NetworkImage를 아래와 같이 사용할 수 있습니다.
private var thumbnailImage: some View {
NetworkImage(source: .string(path: ...)) { phase in
switch phase {
case .empty:
...
case .success(let image):
...
case .failure(let error):
...
}
}
}
phase를 계속 분기해줘야해서 개발자 입장에서 사용하기 굉장히 불편해보입니다. 기존에 사용하던 AsyncImage의 몇가지 추가 생성자도 구현해봅시다.
extension NetworkImage {
init<I, P>(
source: NetworkImageSource,
@ViewBuilder content: @escaping (Image) -> I,
@ViewBuilder placeholder: @escaping () -> P
) where Content == _ConditionalContent<I, P>,
I : View,
P : View {
self.init(source: source) { phase in
switch phase {
case .success(let image):
content(image)
case .empty, .failure:
placeholder()
}
}
}
}
private var thumbnailImage: some View {
NetworkImage(
source: .url(url: thumbnailURL),
content: { image in
image
}, placeholder: {
ProgressView()
}
)
}
이제 AsyncImage처럼 구현할 수 있게 되었습니다. 추가적으로 이미지 로딩에 실패했을 시나리오에 대한 생성자도 추가해봅시다.
extension NetworkImage {
init<I, P, F>(
source: NetworkImageSource,
@ViewBuilder content: @escaping (Image) -> I,
@ViewBuilder placeholder: @escaping () -> P,
@ViewBuilder failure: @escaping (Error) -> F
) where Content == _ConditionalContent<_ConditionalContent<I, P>, F>,
I : View,
P : View,
F : View {
self.init(source: source) { phase in
switch phase {
case .success(let image):
content(image)
case .empty:
placeholder()
case .failure(let error):
failure(error)
}
}
}
}
private var thumbnailImage: some View {
NetworkImage(
source: .url(url: thumbnailURL),
content: { image in
image
}, placeholder: {
ProgressView()
}, failure: { error in
Text("An error has occurred: \(error.localizedDescription)")
}
)
}
이제 이미지 로딩전에는 placeholder에 정의된 View가 보여지게 되고, 이미지 로드 성공시에는 content에 정의된 View가 보여지며 로딩 실패시에는 failure에 정의된 Text가 보여지게 됩니다.
결론
⋯
참고
https://bignerdranch.com/blog/asynchronously-load-images-with-customized-asyncimage-view-in-swiftui/