diff --git a/Airship/Airship.xcodeproj/project.pbxproj b/Airship/Airship.xcodeproj/project.pbxproj index c48cfbd54..593388760 100644 --- a/Airship/Airship.xcodeproj/project.pbxproj +++ b/Airship/Airship.xcodeproj/project.pbxproj @@ -212,6 +212,9 @@ 6E0B8762294CE0DC0064B7BD /* FarmHashFingerprint64.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0B8761294CE0DC0064B7BD /* FarmHashFingerprint64.swift */; }; 6E0B8763294CE0DC0064B7BD /* FarmHashFingerprint64.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0B8761294CE0DC0064B7BD /* FarmHashFingerprint64.swift */; }; 6E0B8764294CE0DC0064B7BD /* FarmHashFingerprint64.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0B8761294CE0DC0064B7BD /* FarmHashFingerprint64.swift */; }; + 6E0F557F2AE03BB900E7CB8C /* ThomasAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0F557E2AE03BB900E7CB8C /* ThomasAsyncImage.swift */; }; + 6E0F55802AE03BB900E7CB8C /* ThomasAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0F557E2AE03BB900E7CB8C /* ThomasAsyncImage.swift */; }; + 6E0F55812AE03BB900E7CB8C /* ThomasAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0F557E2AE03BB900E7CB8C /* ThomasAsyncImage.swift */; }; 6E12539129A81ACE0009EE58 /* AirshipCoreDataPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E12539029A81ACE0009EE58 /* AirshipCoreDataPredicate.swift */; }; 6E12539229A81ACE0009EE58 /* AirshipCoreDataPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E12539029A81ACE0009EE58 /* AirshipCoreDataPredicate.swift */; }; 6E12539329A81ACE0009EE58 /* AirshipCoreDataPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E12539029A81ACE0009EE58 /* AirshipCoreDataPredicate.swift */; }; @@ -2101,6 +2104,7 @@ 6E0B874B294A9D590064B7BD /* AirshipAutomationSDKModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipAutomationSDKModule.swift; sourceTree = ""; }; 6E0B875F294CE0BF0064B7BD /* FarmHashFingerprint64Test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FarmHashFingerprint64Test.swift; sourceTree = ""; }; 6E0B8761294CE0DC0064B7BD /* FarmHashFingerprint64.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FarmHashFingerprint64.swift; sourceTree = ""; }; + 6E0F557E2AE03BB900E7CB8C /* ThomasAsyncImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasAsyncImage.swift; sourceTree = ""; }; 6E12138C1E5D1B95006738FD /* UAScheduleDelay.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UAScheduleDelay.h; sourceTree = ""; }; 6E12138D1E5D1B95006738FD /* UAScheduleDelay.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = UAScheduleDelay.m; sourceTree = ""; }; 6E12539029A81ACE0009EE58 /* AirshipCoreDataPredicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipCoreDataPredicate.swift; sourceTree = ""; }; @@ -3432,7 +3436,6 @@ 6E062CFF27165642001A74A1 /* Thomas */ = { isa = PBXGroup; children = ( - 6E8873982763D80400AC248A /* Image Loading */, 6EF02DEF2714EB500008B6C9 /* Thomas.swift */, 6E46A27E272B68660089CDE3 /* ThomasDelegate.swift */, 6ED80799273DA56000D1F455 /* ThomasViewController.swift */, @@ -3549,6 +3552,7 @@ 6E1C9C42271F744E009EF9EF /* Views */ = { isa = PBXGroup; children = ( + 6E0F557E2AE03BB900E7CB8C /* ThomasAsyncImage.swift */, 6E94761429BBC0230025F364 /* AirshipButton.swift */, 6E71129A2880DACB004942E4 /* StateController.swift */, 6E46A272272B19760089CDE3 /* ViewExtensions.swift */, @@ -3574,7 +3578,6 @@ A658DE182728498900007672 /* AirshipWebview.swift */, 6E152BC92743235800788402 /* Icons.swift */, 6E6541DF2758976D009676CA /* AirshipProgressView.swift */, - 32CF81E1275627F4003009D1 /* AirshipAsyncImage.swift */, 6EF66D902769B69C00ABCB76 /* RootView.swift */, 320AD3A529E7FA2000D66106 /* PagerGestureMap.swift */, ); @@ -4403,6 +4406,7 @@ 6E8873982763D80400AC248A /* Image Loading */ = { isa = PBXGroup; children = ( + 32CF81E1275627F4003009D1 /* AirshipAsyncImage.swift */, A658DE2A272AFB0400007672 /* AirshipImageLoader.swift */, 6E8873992763D8AB00AC248A /* AirshipImageProvider.swift */, ); @@ -4452,6 +4456,7 @@ 6E937300237615B400AA9C2A /* Source */ = { isa = PBXGroup; children = ( + 6E8873982763D80400AC248A /* Image Loading */, 6EC755972A4E114700851ABB /* Audience Checks */, 6E91E42E28EF420700B6F25E /* WorkManager */, 6E49D7CA2840257000C7BB9D /* PermissionsManager */, @@ -6501,6 +6506,7 @@ 6E29474D2AD5DA3B009EC6DD /* LiveActivityRegistrationStatus.swift in Sources */, 6E4E5B3D26E7F91600198175 /* Attributes.swift in Sources */, A658DE0C2727020200007672 /* ImageButton.swift in Sources */, + 6E0F557F2AE03BB900E7CB8C /* ThomasAsyncImage.swift in Sources */, 6E43219226EA89B6009228AB /* NativeBridgeDelegate.swift in Sources */, 6E0B8762294CE0DC0064B7BD /* FarmHashFingerprint64.swift in Sources */, 6EE49C082A0BE9F600AB1CF4 /* RemoteDataURLFactory.swift in Sources */, @@ -6978,6 +6984,7 @@ A6722A78281A9EB90033F54D /* JSONPredicate.swift in Sources */, A6722A79281A9EB90033F54D /* JSONUtils.swift in Sources */, A6722A7A281A9EB90033F54D /* JSONValueMatcher.swift in Sources */, + 6E0F55812AE03BB900E7CB8C /* ThomasAsyncImage.swift in Sources */, 60D3BCC92A152A1800E07524 /* ExperimentDataProvider.swift in Sources */, A6722A7B281A9EB90033F54D /* AirshipJSON.swift in Sources */, A6722A23281A9EA00033F54D /* ModifyAttributesAction.swift in Sources */, @@ -7587,6 +7594,7 @@ 6E29474E2AD5DA3B009EC6DD /* LiveActivityRegistrationStatus.swift in Sources */, 6E692B0829E0DA5200D96CCC /* NativeBridgeExtensionDelegate.swift in Sources */, 6E43219326EA89B6009228AB /* NativeBridgeDelegate.swift in Sources */, + 6E0F55802AE03BB900E7CB8C /* ThomasAsyncImage.swift in Sources */, 6E0B8763294CE0DC0064B7BD /* FarmHashFingerprint64.swift in Sources */, 6EE49C092A0BE9F600AB1CF4 /* RemoteDataURLFactory.swift in Sources */, 6E510C242721DA86006D9126 /* ViewConstraintsViewModifier.swift in Sources */, diff --git a/Airship/AirshipCore/Source/AirshipAsyncImage.swift b/Airship/AirshipCore/Source/AirshipAsyncImage.swift index 78c29cb31..7c0548513 100644 --- a/Airship/AirshipCore/Source/AirshipAsyncImage.swift +++ b/Airship/AirshipCore/Source/AirshipAsyncImage.swift @@ -29,16 +29,12 @@ public struct AirshipAsyncImage: View { @State private var imageIndex: Int = 0 @State private var animationTask: Task? @State private var cancellable: AnyCancellable? - - @Environment(\.isVisible) var isVisible: Bool // we use this value not for updating view tree, but for starting stopping animation, - //that's why we need to store the actual value in a separate @State variable - @State private var isImageVisible: Bool = false public var body: some View { content .onAppear { if self.loadedImage != nil { - animateIfNeeded() + startAnimation() } else { self.cancellable = self.imageLoader.load(url: self.url) .receive(on: DispatchQueue.main) @@ -52,15 +48,11 @@ public struct AirshipAsyncImage: View { }, receiveValue: { image in self.loadedImage = image - animateIfNeeded() + startAnimation() } ) } } - .onChange(of: isVisible, perform: { newValue in - self.isImageVisible = newValue - animateIfNeeded() - }) } private var content: some View { @@ -76,18 +68,14 @@ public struct AirshipAsyncImage: View { } } } - - private func animateIfNeeded() { - if isImageVisible { - self.animationTask?.cancel() - self.animationTask = Task { - await animateImage() - } - } else { - self.animationTask?.cancel() + + private func startAnimation() { + self.animationTask?.cancel() + self.animationTask = Task { + await animateImage() } } - + @MainActor private func animateImage() async { guard let loadedImage = self.loadedImage else { return } diff --git a/Airship/AirshipCore/Source/ImageButton.swift b/Airship/AirshipCore/Source/ImageButton.swift index ed5ccda8a..ee454cae2 100644 --- a/Airship/AirshipCore/Source/ImageButton.swift +++ b/Airship/AirshipCore/Source/ImageButton.swift @@ -45,7 +45,7 @@ struct ImageButton : View { private func makeInnerButton() -> some View { switch(model.image) { case .url(let model): - AirshipAsyncImage( + ThomasAsyncImage( url: model.url, imageLoader: thomasEnvironment.imageLoader, image: { image, _ in diff --git a/Airship/AirshipCore/Source/Media.swift b/Airship/AirshipCore/Source/Media.swift index a8a210f49..70f9c0c8a 100644 --- a/Airship/AirshipCore/Source/Media.swift +++ b/Airship/AirshipCore/Source/Media.swift @@ -14,7 +14,7 @@ struct Media: View { var body: some View { switch model.mediaType { case .image: - AirshipAsyncImage( + ThomasAsyncImage( url: self.model.url, imageLoader: thomasEnvironment.imageLoader ) { image, imageSize in diff --git a/Airship/AirshipCore/Source/ThomasAsyncImage.swift b/Airship/AirshipCore/Source/ThomasAsyncImage.swift new file mode 100644 index 000000000..e66fccf24 --- /dev/null +++ b/Airship/AirshipCore/Source/ThomasAsyncImage.swift @@ -0,0 +1,125 @@ +/* Copyright Airship and Contributors */ + +import Combine +import Foundation +import SwiftUI + +public struct ThomasAsyncImage: View { + + let url: String + let imageLoader: AirshipImageLoader + let image: (Image, CGSize) -> ImageView + let placeholder: () -> Placeholder + + public init( + url: String, + imageLoader: AirshipImageLoader = AirshipImageLoader(), + image: @escaping (Image, CGSize) -> ImageView, + placeholder: @escaping () -> Placeholder + ) { + self.url = url + self.imageLoader = imageLoader + self.image = image + self.placeholder = placeholder + } + + @State private var loadedImage: AirshipImageData? = nil + @State private var currentImage: UIImage? + @State private var imageIndex: Int = 0 + @State private var animationTask: Task? + @State private var cancellable: AnyCancellable? + + @Environment(\.isVisible) var isVisible: Bool // we use this value not for updating view tree, but for starting stopping animation, + //that's why we need to store the actual value in a separate @State variable + @State private var isImageVisible: Bool = false + + public var body: some View { + content + .onAppear { + if self.loadedImage != nil { + animateIfNeeded() + } else { + self.cancellable = self.imageLoader.load(url: self.url) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { completion in + if case let .failure(error) = completion { + AirshipLogger.error( + "Unable to load image \(error)" + ) + } + }, + receiveValue: { image in + self.loadedImage = image + animateIfNeeded() + } + ) + } + } + .onChange(of: isVisible, perform: { newValue in + self.isImageVisible = newValue + animateIfNeeded() + }) + } + + private var content: some View { + Group { + if let image = currentImage { + self.image(Image(uiImage: image), image.size) + .animation(nil, value: self.imageIndex) + .onDisappear { + animationTask?.cancel() + } + } else { + self.placeholder() + } + } + } + + private func animateIfNeeded() { + if isImageVisible { + self.animationTask?.cancel() + self.animationTask = Task { + await animateImage() + } + } else { + self.animationTask?.cancel() + } + } + + @MainActor + private func animateImage() async { + guard let loadedImage = self.loadedImage else { return } + + guard loadedImage.isAnimated else { + self.currentImage = loadedImage.loadFrames().first?.image + return + } + + let frameActor = loadedImage.getActor() + + imageIndex = 0 + var frame = await frameActor.loadFrame(at: imageIndex) + + self.currentImage = frame?.image + + while !Task.isCancelled { + let duration = frame?.duration ?? AirshipImageData.minFrameDuration + + async let delay: () = Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + + let nextIndex = (imageIndex + 1) % loadedImage.imageFramesCount + + do { + let (_, nextFrame) = try await (delay, frameActor.loadFrame(at: nextIndex)) + frame = nextFrame + } catch {} // most likely it's a task cancelled exception when animation is stopped + + imageIndex = nextIndex + + if !Task.isCancelled { + self.currentImage = frame?.image + } + } + } +}