-
-
Notifications
You must be signed in to change notification settings - Fork 1
Create helper SwiftUI and UIKit Views #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import UIKit | ||
import Combine | ||
|
||
/// TODO: .... | ||
public class LottieUIKitView: UIView { | ||
private let imageView: UIImageView | ||
private var viewModel: LottieViewModel | ||
private var cancellables = Set<AnyCancellable>() | ||
|
||
/// TODO: .... | ||
public init(lottie: Lottie) { | ||
self.viewModel = LottieViewModel(lottie: lottie, size: lottie.getSize()) | ||
self.imageView = UIImageView() | ||
super.init(frame: .zero) | ||
setupView() | ||
} | ||
|
||
required init?(coder: NSCoder) { | ||
fatalError("init(coder:) has not been implemented") | ||
} | ||
|
||
private func setupView() { | ||
imageView.contentMode = .scaleAspectFit | ||
addSubview(imageView) | ||
imageView.translatesAutoresizingMaskIntoConstraints = false | ||
NSLayoutConstraint.activate([ | ||
imageView.topAnchor.constraint(equalTo: topAnchor), | ||
imageView.bottomAnchor.constraint(equalTo: bottomAnchor), | ||
imageView.leadingAnchor.constraint(equalTo: leadingAnchor), | ||
imageView.trailingAnchor.constraint(equalTo: trailingAnchor) | ||
]) | ||
} | ||
|
||
/// TODO: .... | ||
public func startAnimating() { | ||
viewModel.startAnimating() | ||
viewModel.$renderedFrame | ||
.sink { [weak self] image in | ||
self?.imageView.image = image | ||
} | ||
.store(in: &cancellables) | ||
} | ||
|
||
/// TODO: .... | ||
public func stopAnimating() { | ||
viewModel.stopAnimating() | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
import SwiftUI | ||
|
||
/// TODO: .... | ||
@available(iOS 14.0, *) | ||
public struct LottieView: View { | ||
|
||
@StateObject private var viewModel: LottieViewModel | ||
|
||
// TODO: Later feature - be dynamic with size. | ||
|
||
/// TODO: .... | ||
public init(lottie: Lottie) { | ||
let viewModel = LottieViewModel( | ||
lottie: lottie, | ||
size: lottie.getSize() | ||
) | ||
_viewModel = StateObject(wrappedValue: viewModel) | ||
} | ||
|
||
@ViewBuilder private func content() -> some View { | ||
if let image = viewModel.renderedFrame { | ||
Image(uiImage: image) | ||
.resizable() | ||
.scaledToFit() | ||
} else { | ||
Color.clear | ||
} | ||
} | ||
|
||
/// TODO: .... | ||
public var body: some View { | ||
content() | ||
.onAppear { | ||
viewModel.startAnimating() | ||
} | ||
.onDisappear { | ||
viewModel.stopAnimating() | ||
} | ||
} | ||
} | ||
|
||
#Preview { | ||
if let path = Bundle.module.path(forResource: "test", ofType: "json"), | ||
let lottie = try? Lottie(path: path) { | ||
|
||
if #available(iOS 14.0, *) { | ||
LottieView(lottie: lottie) | ||
} else { | ||
Text("Unsupported iOS Version.") | ||
} | ||
} else { | ||
Text("Failed to load Lottie file.") | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,113 @@ | ||||||||||
import Combine | ||||||||||
import SwiftUI | ||||||||||
|
||||||||||
/// TODO: .... | ||||||||||
class LottieViewModel: ObservableObject { | ||||||||||
private let totalFrames: Float | ||||||||||
private let size: CGSize | ||||||||||
private var buffer: [UInt32] | ||||||||||
private let renderer: LottieRenderer | ||||||||||
|
||||||||||
private var timer: AnyCancellable? | ||||||||||
|
||||||||||
private var currentFrame: Float = 0 | ||||||||||
private let frameRate: Double = 30.0 | ||||||||||
|
||||||||||
@Published var renderedFrame: UIImage? = nil | ||||||||||
|
||||||||||
// TODO: Handle different engine types. | ||||||||||
init(lottie: Lottie, size: CGSize) { | ||||||||||
var buffer = [UInt32](repeating: 0, count: Int(size.width * size.height)) | ||||||||||
self.renderer = LottieRenderer( | ||||||||||
lottie, | ||||||||||
engine: .main, | ||||||||||
size: size, | ||||||||||
buffer: &buffer, | ||||||||||
stride: Int(size.width), | ||||||||||
pixelFormat: .argb | ||||||||||
) | ||||||||||
self.buffer = buffer | ||||||||||
self.totalFrames = lottie.numberOfFrames | ||||||||||
self.size = size | ||||||||||
} | ||||||||||
|
||||||||||
func startAnimating() { | ||||||||||
let frameDuration = 1.0 / frameRate | ||||||||||
timer = Timer.publish(every: frameDuration, on: .main, in: .common) | ||||||||||
.autoconnect() | ||||||||||
.sink { [weak self] _ in | ||||||||||
self?.renderNextFrame() | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
func stopAnimating() { | ||||||||||
timer?.cancel() | ||||||||||
timer = nil | ||||||||||
} | ||||||||||
|
||||||||||
// TODO: Handle errors propery here. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a typo in the comment: 'propery' should be 'properly'.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||||
private func renderNextFrame() { | ||||||||||
guard currentFrame < totalFrames else { | ||||||||||
currentFrame = 0 | ||||||||||
return | ||||||||||
} | ||||||||||
|
||||||||||
let contentRect = CGRect(x: 0, y: 0, width: size.width, height: size.height) | ||||||||||
|
||||||||||
do { | ||||||||||
try renderer.render(frameIndex: Float(currentFrame), contentRect: contentRect) | ||||||||||
} catch { | ||||||||||
print(error) | ||||||||||
fatalError("Rendering error.") | ||||||||||
Comment on lines
+60
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The error message 'Rendering error.' is too generic and doesn't provide useful debugging information. Consider including the actual error details or a more descriptive message.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||||
} | ||||||||||
|
||||||||||
|
||||||||||
|
||||||||||
if let image = UIImage(buffer: &buffer, size: size, pixelFormat: .argb) { | ||||||||||
renderedFrame = image | ||||||||||
currentFrame += 1 | ||||||||||
} else { | ||||||||||
print("UI IMAGE CAST ERROR") | ||||||||||
fatalError("UIImage cast error.") | ||||||||||
Comment on lines
+70
to
+71
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The error messages are inconsistent in style (all caps vs sentence case) and not descriptive. Consider using a consistent format and providing more context about what failed during the UIImage creation.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||||
} | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
extension UIImage { | ||||||||||
convenience init?(buffer: Buffer, size: CGSize, pixelFormat: PixelFormat) { | ||||||||||
let colorSpace = CGColorSpaceCreateDeviceRGB() | ||||||||||
let bitmapInfo = pixelFormat.bitmapInfo.rawValue | ||||||||||
let bitsPerComponent = 8 | ||||||||||
let bytesPerRow = Int(size.width) * 4 | ||||||||||
|
||||||||||
guard let context = CGContext( | ||||||||||
data: buffer, | ||||||||||
width: Int(size.width), | ||||||||||
height: Int(size.height), | ||||||||||
bitsPerComponent: bitsPerComponent, | ||||||||||
bytesPerRow: bytesPerRow, | ||||||||||
space: colorSpace, | ||||||||||
bitmapInfo: bitmapInfo | ||||||||||
) else { | ||||||||||
return nil | ||||||||||
} | ||||||||||
|
||||||||||
guard let cgImage = context.makeImage() else { | ||||||||||
return nil | ||||||||||
} | ||||||||||
|
||||||||||
self.init(cgImage: cgImage, scale: 1.0, orientation: .up) | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
extension PixelFormat { | ||||||||||
var bitmapInfo: CGBitmapInfo { | ||||||||||
switch self { | ||||||||||
case .argb: | ||||||||||
return [.byteOrder32Little, CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)] | ||||||||||
case .abgr: | ||||||||||
return [.byteOrder32Big, CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)] | ||||||||||
} | ||||||||||
} | ||||||||||
} | ||||||||||
|
Large diffs are not rendered by default.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The subscription to
viewModel.$renderedFrame
is created every timestartAnimating()
is called, but previous subscriptions are not cancelled. This will create multiple subscriptions and potential memory leaks. Consider cancelling existing subscriptions first or moving this subscription tosetupView()
.Copilot uses AI. Check for mistakes.