Does anybody have an example for how to display an...
# multiplatform
d
Does anybody have an example for how to display an image asynchronously in SwiftUI? I've managed to find ways to load the data and to share the data from ByteArray to NSData, but I'm stumped with having SwiftUI make the call down to retrieve the data and then show it. If this off-topic, sorry in advance and please point me to the right channel to ask.
b
a
We may have some code I can pull for this, but I'll have to look a bit. We have moved to doing all networking in Kotlin since and do have methods for handlying the ByteArry to NSData problem that I'll share
These are methods we use to convert ByteArray and NSData. They do need to go in your apple ios target folders of course.
Copy code
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.usePinned
import platform.Foundation.NSData
import platform.Foundation.dataWithBytes
import platform.UIKit.UIImage
import platform.posix.memcpy

/**
 * Convert this Data to a Kotlin ByteArray.
 */
fun NSData.toByteArray(): ByteArray = ByteArray(this.length.toInt()).apply {
    usePinned {
        memcpy(it.addressOf(0), this@toByteArray.bytes, this@toByteArray.length)
    }
}

/**
 * Convert this ByteArray to Apple NSData.
 */
fun ByteArray.toNSData(): NSData = this.usePinned {
    NSData.dataWithBytes(it.addressOf(0), it.get().size.toULong())
}

/**
 * Convert this ByteArray to a UIImage, with scale value.
 */
fun ByteArray.toUIImage(scale: Double): UIImage? = memScoped {
    val nsData = this@toUIImage.toNSData()
    UIImage.imageWithData(nsData, scale)
}

/**
 * Convert this ByteArray to a UIImage.
 */
fun ByteArray.toUIImage(): UIImage? = this.toUIImage(1.0)
❤️ 1
d
@Anonymike that last method looks like exactly what I'm looking for. Will report back after testing it!
a
You got it and if you spot any improvements, please share too!
Oh, and its important to note that we are trying to avoid copies in these methods so expect any modifications to the image data to manipulate the original as well. We do a lot of dynamic imaging from the server so the performance was preferable for us and we use this to load thousands of optimized images, so its worked well in production.
d
@Anonymike The only stumbling point I'm hitting ATM is that the data is coming out of an archive, so the method to extract the data is a suspend function. I'm not sure how in SwiftUI to have the Image use the returned value from ByteArray.toUIImage.
I tried doing the load from within the init() function for the view, but that didn't work. 😕
a
We load images from a network via suspending function as well, but have used KMPNativeCoroutines and the default KMP behaviors to make sure we're only asking for the data after its loaded.
d
Hrm, I'm using KMPNativeCoroutines as well...
a
Let me see if I can find a usage example to help
d
I have to call a function:
Copy code
ArchiveAPI.loadFile(archiveFilename, entryFilename)
which is a suspend function that returns a ByteArray.
I've added the extensions to return the data as an NSData type and also as a UIImage? as well.
I'm just not wrapping my head around how to call that from SwiftUI to load the image and display it.
a
We use a library like SDImage for handling WebP imagery in swift. Our coroutine returns an
ImageServerResponse
, here's that extension and I'll look for a more to connect the dots.
Copy code
extension ImageServiceResponse {

    ///
    /// Convert this ImageServiceResponse to a UIImage. Adds support for Webp decoding.
    /// - Returns: The resulting UIImage if decoding was successful.
    ///
    func toUIImage() -> UIImage? {
        if (type == "webp") {
            return SDImageWebPCoder.shared.decodedImage(with: self.data.toNSData(), options: nil)
        } else {
            return self.data.toUIImage()
        }
    }

}
That is essentially where our common shared Kotlin code meets Swift
Our shared code has the following method (kept as a prototype to simplify it):
Copy code
suspend fun requestImage(image: String, params: String? = null): ImageServiceResponse?

// It returns something like this:
if (response.status == HttpStatusCode.OK) {
                ImageServiceResponse(image, params, "webp", response.body<ByteArray>())
            } else {
                null
            }
d
I'm about to try this code:
Copy code
var coverImage: UIImage? {
        if comicBook.pages.count > 0 {
            let coverPage = (comicBook.pages[0] as! ComicPage)
            Task {
                let imageData = try await ArchiveAPI().loadPage(
                    comicFilename: comicBook.filename,
                    pageFilename: coverPage.filename
                )

                if (imageData != nil) {
                    return imageData!.toUIImage()
                }
                
                return nil
            }
        }

        return nil
    }
👀 1
a
This is a bit more complex, but should show you the SwiftUI connection:
Copy code
import shared
import SwiftUI

struct AsyncImageView<Content, Content2>: View where Content: View, Content2: View {
    private let imageName: String?
    private let imageParams: String?
    private let imageKey: String
    private let transaction: Transaction?
    private let contentPhase: ((AsyncImagePhase) -> Content)?
    private let contentImage: ((Image) -> Content)?
    private let placeholder: (() -> Content2)?

    private let imageService: ImageRequestService = koin.get()
    @State private var imageStatus: AsyncImagePhase = .empty
    @State private var imageWasRequested: Bool = false

    private let log: Logger = koin.log(name: "AsyncImageService")

    init(
        name: String?,
        params: String? = nil,
        transaction: Transaction = Transaction(),
        @ViewBuilder content: @escaping (AsyncImagePhase) -> Content
    ) {
        imageName = name
        imageParams = (params?.isEmpty == true) ? nil : params
        if (imageName == nil) {
            imageKey = "none"
        } else {
            imageKey = "\(imageName!)\((imageParams == nil) ? "" : "?\(imageParams!)")"
        }

        self.transaction = transaction
        self.contentPhase = content
        self.contentImage = nil
        self.placeholder = { EmptyView() as! Content2 }
    }

    init(
        name: String?,
        params: String? = nil,
        @ViewBuilder content: @escaping (Image) -> Content,
        @ViewBuilder placeholder: @escaping () -> Content2
    ) {
        imageName = name
        imageParams = (params?.isEmpty == true) ? nil : params
        if (imageName == nil) {
            imageKey = "none"
        } else {
            imageKey = "\(imageName!)\((imageParams == nil) ? "" : ("?" + imageParams!))"
        }

        self.contentImage = content
        self.placeholder = placeholder
        self.contentPhase = nil
        self.transaction = nil
    }

    var body: some View {
        if imageName == nil {
            if contentPhase != nil {
                contentPhase!(.empty)
            } else if placeholder != nil {
                placeholder!()
            }
        } else if let cached = ImageCache[imageKey] {
            #if DEBUG
            let _ = print("cached: \(imageKey)")
            #endif
            if contentPhase != nil {
                contentPhase?(.success(cached))
            } else if contentImage != nil {
                contentImage?(cached)
            }
        } else {
            #if DEBUG
            let _ = print("request: \(imageKey)")
            #endif
            if contentPhase != nil {
                contentPhase!(imageStatus).task { await loadImage() }
            } else if contentImage != nil && placeholder != nil {
                switch (imageStatus) {
                    case .success (let image): cacheAndRender(image: image)
                    case .empty: placeholder!().task { await loadImage() }
                    case .failure: placeholder!()
                    default: placeholder!().task { await loadImage() }
                }
            }
        }
    }

    private func loadImage() async {
        if (imageWasRequested) {
            return
        }

        #if DEBUG
        log.i(messageString: "Loading Image : \(imageKey)")
        #endif
        imageWasRequested = true;
        guard let imageName else { imageStatus = .empty; return }

        let response = try? await imageService.requestImage(image: imageName, params: imageParams)
        if let imageResponse = response?.toUIImage() {
            imageStatus = .success(Image(uiImage: imageResponse))
        } else {
            imageStatus = .failure(AsyncImageServiceError.requestFailed)
        }
    }

    private func cacheAndRender(image: Image) -> some View {
        ImageCache[imageKey] = image
        return contentImage?(image)
    }

    private func cacheAndRender(phase: AsyncImagePhase) -> some View{
        if case .success (let image) = phase {
            ImageCache[imageKey] = image
        }
        return contentPhase?(phase)
    }
}

enum AsyncImageServiceError: Error {
    case requestFailed
}

fileprivate class ImageCache {
    static private var cache: [String: Image] = [:]

    static subscript(key: String?) -> Image? {
        get {
            guard let key else { return nil }
            return ImageCache.cache[key]
        }
        set {
            guard let key else { return }
            ImageCache.cache[key] = newValue
        }
    }
}
👍 1
d
@Anonymike Thank you! Going to digest that and see if I can make it work. Thank you so much!
a
No problem...this stuff was a pain to solve but we don't mind sharing it. Just let me know how it goes and share any improvements if you don't mind.
d
@Anonymike I ended up using what you showed previously and putting into an ObservableObject named ImageLoader. Then I publish the image and a flag to say when it's loaded, and I'm getting what I want now. Thank you again!
Copy code
class ImageLoader: ObservableObject {
    private var comicBook: ComicBook
    private var pageFilename: String = ""
    
    @Published var image: UIImage? = nil
    @Published var hasImage: Bool = false
    
    init(comicBook: ComicBook) {
        self.comicBook = comicBook
        
        if (self.comicBook.pages.count > 0) {
            self.pageFilename = (self.comicBook.pages[0] as! ComicPage).filename
            doLoadPage()
        }
    }
    
    private func doLoadPage() {
        Task {
            Log().debug(
                tag: TAG,
                message:
                    "Loading cover image: \(comicBook.path):\(self.pageFilename)"
            )
            let imageData = try await ArchiveAPI().loadPage(
                comicFilename: comicBook.path,
                pageFilename: self.pageFilename
            )

            if imageData != nil {
                self.image = imageData!.toUIImage()
                self.hasImage = true
            }
        }

    }
}
🙌 1
a
Nice! Very happy it worked for you and good luck with the rest of the app!
❤️ 1