Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/workflows/Tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,21 @@ concurrency:

jobs:
Tests:
runs-on: macos-15-xlarge
runs-on: macos-26-xlarge
steps:
- name: Cancel previous jobs
uses: styfle/cancel-workflow-action@0.12.1

- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Load Latest Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

- name: Build project
run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild build-for-testing -destination 'name=iPhone 16 Pro' -scheme 'PovioKit-Package' | xcbeautify --renderer github-actions
run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild build-for-testing -destination 'name=iPhone 17 Pro,OS=26.0' -scheme 'PovioKit-Package' | xcpretty

- name: Run tests
run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild test-without-building -destination 'name=iPhone 16 Pro' -scheme 'PovioKit-Package' | xcbeautify --renderer github-actions

run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild test-without-building -destination 'name=iPhone 17 Pro,OS=26.0' -scheme 'PovioKit-Package' | xcpretty
10 changes: 9 additions & 1 deletion Demo/Storybook/Storybook.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objectVersion = 70;
objects = {

/* Begin PBXBuildFile section */
Expand Down Expand Up @@ -37,6 +37,10 @@
999EF7C72D6E081800F280E8 /* LinearProgressStyleComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinearProgressStyleComponent.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedRootGroup section */
9986B6EE2E2F90A100C393C2 /* Resources */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Resources; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */

/* Begin PBXFrameworksBuildPhase section */
998E6EF0297E82F700D33909 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
Expand Down Expand Up @@ -86,6 +90,7 @@
998E6EF5297E82F700D33909 /* Storybook */ = {
isa = PBXGroup;
children = (
9986B6EE2E2F90A100C393C2 /* Resources */,
998E6F0B297E936D00D33909 /* Components */,
998E6EF6297E82F700D33909 /* StorybookApp.swift */,
998E6EF8297E82F700D33909 /* ContentView.swift */,
Expand Down Expand Up @@ -132,6 +137,9 @@
);
dependencies = (
);
fileSystemSynchronizedGroups = (
9986B6EE2E2F90A100C393C2 /* Resources */,
);
name = Storybook;
packageProductDependencies = (
990B5C8C2D2C0C7C00F2963F /* PovioKitSwiftUI */,
Expand Down
72 changes: 68 additions & 4 deletions Demo/Storybook/Storybook/Components/AnimatedImageComponent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,81 @@ import SwiftUI
import PovioKitSwiftUI

struct AnimatedImageComponent: View {
@State private var animationStarted = false
@State private var animationEnded = false

var body: some View {
if let url = URL(string: "https://shorturl.at/9q4YZ") {
AnimatedImage(source: .remote(url: url))
VStack(spacing: 20) {
// Basic usage
VStack {
Text("Basic Animated Image")
.font(.headline)
AnimatedImage(source: .local(fileName: "animation"))
.squared()
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(.gray, lineWidth: 1)
}
}

// Customized usage with repeat count and callbacks
VStack {
Text("Customized Animated Image")
.font(.headline)
AnimatedImage(
source: .local(fileName: "animation"),
animated: true,
autoStartAnimation: true,
repeatCount: .finite(count: 3), // Play 3 times then stop
onAnimationStart: {
animationStarted = true
print("Animation started!")
},
onAnimationEnd: {
animationEnded = true
print("Animation ended!")
}
)
.squared()
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(.gray, lineWidth: 1)
.stroke(.blue, lineWidth: 2)
}
.padding(20)
}

// Manual control example
VStack {
Text("Manual Control")
.font(.headline)
AnimatedImage(
source: .local(fileName: "animation"),
animated: true,
autoStartAnimation: false // Don't start automatically
)
.squared()
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(.green, lineWidth: 2)
}
}

// Status indicators
VStack(spacing: 8) {
Text("Animation Status:")
.font(.caption)
Text("Started: \(animationStarted ? "Yes" : "No")")
.font(.caption)
.foregroundColor(animationStarted ? .green : .red)
Text("Ended: \(animationEnded ? "Yes" : "No")")
.font(.caption)
.foregroundColor(animationEnded ? .green : .red)
}
.padding(.top)
}
.padding(20)
}
}

Expand Down
Binary file added Demo/Storybook/Storybook/Resources/animation.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions Resources/UI/SwiftUI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A package including components to help you out developing for SwiftUI framework.
| [MaterialBlurView](/Sources/UI/SwiftUI/Views/MaterialBlurView/MaterialBlurView.swift) | all | Material blur effects view |
| [PhotoPickerView](/Sources/UI/SwiftUI/Views/PhotoPickerView/PhotoPickerView.swift) | all | Photo and Camera picker view used in combination with `PhotoPickerModifier` |
| [RemoteImage](/Sources/UI/SwiftUI/Views/RemoteImage/RemoteImage.swift) | all | Fetching remote images using Kingfisher |
| [AnimatedImage](/Sources/UI/SwiftUI/Views/AnimatedImage/AnimatedImage.swift) | all | Fetching remote or local GIF images using Kingfisher |
| [ScrollViewWithOffset](/Sources/UI/SwiftUI/Views/ScrollViewWithOffset/ScrollViewWithOffset.swift) | all | ScrollView that expose offset as we scroll |
| [SimpleColorPicker](/Sources/UI/SwiftUI/Views/SimpleColorPicker/SimpleColorPicker.swift) | macOS | Wrapper for NSColorWell component |

Expand Down
89 changes: 84 additions & 5 deletions Sources/UI/SwiftUI/Views/AnimatedImage/AnimatedImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,107 @@
import Kingfisher
import SwiftUI

/// A view that displays an animated image either from a local file or a remote URL.
/// A view that displays an animated image (a.k.a GIF) either from a local file or a remote URL.
///
/// It uses `Kingfisher` for handling the image loading from both sources.
/// This implementation provides access to advanced animation customization options.
@available(iOS 15.0, *)
public struct AnimatedImage: View {
private let source: Source
private let animated: Bool
private let autoStartAnimation: Bool
private let repeatCount: AnimatedImageView.RepeatCount
private let onAnimationStart: (() -> Void)?
private let onAnimationEnd: (() -> Void)?

public init(source: Source, animated: Bool = false) {
public init(
source: Source,
animated: Bool = false,
autoStartAnimation: Bool = true,
repeatCount: AnimatedImageView.RepeatCount = .infinite,
onAnimationStart: (() -> Void)? = nil,
onAnimationEnd: (() -> Void)? = nil
) {
self.source = source
self.animated = animated
self.autoStartAnimation = autoStartAnimation
self.repeatCount = repeatCount
self.onAnimationStart = onAnimationStart
self.onAnimationEnd = onAnimationEnd
}

public var body: some View {
AnimatedImageViewRepresentable(
source: source,
animated: animated,
autoStartAnimation: autoStartAnimation,
repeatCount: repeatCount,
onAnimationStart: onAnimationStart,
onAnimationEnd: onAnimationEnd
)
}
}

@available(iOS 15.0, *)
private struct AnimatedImageViewRepresentable: UIViewRepresentable {
let source: AnimatedImage.Source
let animated: Bool
let autoStartAnimation: Bool
let repeatCount: AnimatedImageView.RepeatCount
let onAnimationStart: (() -> Void)?
let onAnimationEnd: (() -> Void)?

func makeUIView(context: Context) -> AnimatedImageView {
let imageView = AnimatedImageView()
imageView.autoPlayAnimatedImage = autoStartAnimation
imageView.repeatCount = repeatCount
imageView.delegate = context.coordinator
return imageView
}

func updateUIView(_ imageView: AnimatedImageView, context: Context) {
// Update the image source
switch source {
case .local(let fileName):
if let fileUrl = Bundle.main.url(forResource: fileName, withExtension: "gif") {
KFAnimatedImage(source: .provider(LocalFileImageDataProvider(fileURL: fileUrl)))
.fade(duration: animated ? 0.25 : 0)
imageView.kf.setImage(
with: .provider(LocalFileImageDataProvider(fileURL: fileUrl)),
options: [
.transition(.fade(animated ? 0.25 : 0))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this animation something that should be controlled by the user instead?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Let me think about it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've landed up opening all the options available to be customized.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lmk how does that feel.

]
)
}
case .remote(let url):
KFAnimatedImage(url)
if let url = url {
imageView.kf.setImage(
with: url,
options: [
.transition(.fade(animated ? 0.25 : 0))
]
)
}
}
}

func makeCoordinator() -> Coordinator {
Coordinator(onAnimationStart: onAnimationStart, onAnimationEnd: onAnimationEnd)
}

class Coordinator: NSObject, AnimatedImageViewDelegate {
let onAnimationStart: (() -> Void)?
let onAnimationEnd: (() -> Void)?

init(onAnimationStart: (() -> Void)?, onAnimationEnd: (() -> Void)?) {
self.onAnimationStart = onAnimationStart
self.onAnimationEnd = onAnimationEnd
}

func animatedImageView(_ imageView: AnimatedImageView, didStartAnimating image: KFCrossPlatformImage) {
onAnimationStart?()
}

func animatedImageView(_ imageView: AnimatedImageView, didStopAnimating image: KFCrossPlatformImage) {
onAnimationEnd?()
}
}
}
Expand Down