[SwiftUI] Custom AsyncImage 만들기

서론

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/