From 4b46fdc81942f067bee52642bc61b656e5222e10 Mon Sep 17 00:00:00 2001 From: Anton Stefer Date: Wed, 4 Feb 2026 15:39:50 +0100 Subject: [PATCH 01/20] chore: Init claude code --- CLAUDE.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..25faa2e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Flutter plugin that wraps native Lottie animation libraries (lottie-ios and lottie-android) to render animations using platform-specific implementations. Forked from flutter_lottie. + +## Common Commands + +```bash +# Install melos (if not installed) +dart pub global activate melos + +# Bootstrap packages +melos bootstrap + +# Run analysis +melos run analyze + +# Check formatting (CI uses this) +melos run format:check:dart + +# Format code +melos run format:dart +``` + +## Architecture + +### Platform Channel Communication + +The plugin uses Flutter's Method Channel and Event Channel pattern: +- **Method Channels** (`de.lotum/lottie_native_[id]`): Send commands to native (play, pause, stop, etc.) +- **Event Channels** (`de.lotum/lottie_native_state_[id]`): Stream animation state changes (loaded, started, finished, cancelled) + +Each LottieView instance gets unique channels using its viewId. + +### Key Components + +**Dart (lib/src/):** +- `LottieView` - Widget with factory constructors: `fromURL`, `fromAsset`, `fromJson` +- `LottieController` - Animation lifecycle management, exposes play/pause/stop and state streams +- `LOTValue` - Type system for dynamic animation properties (LOTColorValue, LOTOpacityValue) + +**Android (android/src/main/kotlin/):** +- `LottieNativePlugin` - Plugin entry point +- `LottieView` - Wraps LottieAnimationView, handles MethodCall routing + +**iOS (ios/Classes/):** +- `LottieNativePlugin` - Plugin entry point +- `LottieView` - Wraps LottieAnimationView, implements FlutterStreamHandler + +### Native Dependencies +- Android: `com.airbnb.android:lottie:6.3.0` +- iOS: `lottie-ios ~> 4.4.3` (requires iOS 13.0+) + +## Monorepo Structure + +Uses Melos to manage root plugin + example app. The example app in `example/` contains sample animations and demonstrates the API. \ No newline at end of file From 1f63896cbe5fc6054b2c7ec0d61c878ee0226ee9 Mon Sep 17 00:00:00 2001 From: Anton Stefer Date: Wed, 4 Feb 2026 15:41:13 +0100 Subject: [PATCH 02/20] build(example): Update Gradle config for Flutter 3.35 --- example/android/app/build.gradle | 54 +++++-------------- example/android/build.gradle | 17 +----- .../gradle/wrapper/gradle-wrapper.properties | 2 +- example/android/settings.gradle | 30 ++++++++--- example/ios/Flutter/AppFrameworkInfo.plist | 2 +- example/ios/Podfile.lock | 6 +-- example/ios/Runner.xcodeproj/project.pbxproj | 8 +-- .../xcshareddata/xcschemes/Runner.xcscheme | 5 +- example/ios/Runner/AppDelegate.swift | 4 +- 9 files changed, 51 insertions(+), 77 deletions(-) diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index b77b30a..d433e93 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -1,73 +1,43 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" } -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - android { ndkVersion flutter.ndkVersion compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = "17" } sourceSets { - main.java.srcDirs += 'src/main/kotlin' + main.java.srcDirs += "src/main/kotlin" } - namespace 'de.lotum.lottie_native_example' + namespace "de.lotum.lottie_native_example" defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "de.lotum.lottie_native_example" - // You can update the following values to match your application needs. - // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. minSdkVersion flutter.minSdkVersion targetSdkVersion flutter.targetSdkVersion compileSdk flutter.compileSdkVersion - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName + versionCode 1 + versionName "1.0" } buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.debug } } } flutter { - source '../..' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + source "../.." } diff --git a/example/android/build.gradle b/example/android/build.gradle index 1ad09f5..d2ffbff 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,16 +1,3 @@ -buildscript { - ext.kotlin_version = '1.7.10' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:8.2.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() @@ -18,12 +5,12 @@ allprojects { } } -rootProject.buildDir = '../build' +rootProject.buildDir = "../build" subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" } subprojects { - project.evaluationDependsOn(':app') + project.evaluationDependsOn(":app") } tasks.register("clean", Delete) { diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index b5fc5a7..afa1e8e 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 44e62bc..8cbd262 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -1,11 +1,25 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.7.0" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false +} + +include ":app" diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 9625e10..1dc6cf7 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 11.0 + 13.0 diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index ac021cf..2b8c18f 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -20,10 +20,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/lottie_native/ios" SPEC CHECKSUMS: - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 lottie-ios: fcb5e73e17ba4c983140b7d21095c834b3087418 - lottie_native: d7625e69e3104fa40a333ab2ed9ae77409b2777e + lottie_native: 4f3a9ef106799160946396cf50746ad169211e23 PODFILE CHECKSUM: d2243213672c3c48aae53c36642ba411a6be7309 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 4fefd0e..00744be 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -164,7 +164,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = "The Chromium Authors"; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -350,7 +350,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; @@ -432,7 +432,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -479,7 +479,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 9997cfd..0301da1 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ @@ -46,12 +47,14 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" language = "" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 71cc41e..b636303 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -1,11 +1,11 @@ import UIKit import Flutter -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]? + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) From 0c0c23b9601851ea0e698eddc6f8876c03e12c42 Mon Sep 17 00:00:00 2001 From: Anton Stefer Date: Wed, 4 Feb 2026 15:41:46 +0100 Subject: [PATCH 03/20] build(android): Update Kotlin 2.1.0, AGP 8.7.0, Java 17 --- android/build.gradle | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index c7fafcf..331610d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -2,7 +2,7 @@ group 'de.lotum.lottie_native' version '1.0-SNAPSHOT' buildscript { - ext.kotlin_version = '1.7.10' + ext.kotlin_version = '2.1.0' repositories { google() @@ -10,7 +10,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:8.2.0' + classpath 'com.android.tools.build:gradle:8.7.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -26,15 +26,15 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' android { - compileSdkVersion 33 + compileSdk 34 compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '17' } namespace 'de.lotum.lottie_native' From f4161ba4ef0743f89b60fcd96ff7c8306bae898f Mon Sep 17 00:00:00 2001 From: Anton Stefer Date: Wed, 4 Feb 2026 15:42:49 +0100 Subject: [PATCH 04/20] fix(example): Use AppCompat theme for LottieAnimationView --- example/android/app/src/main/res/values-night/styles.xml | 4 ++-- example/android/app/src/main/res/values/styles.xml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/example/android/app/src/main/res/values-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml index 06952be..468b2b3 100644 --- a/example/android/app/src/main/res/values-night/styles.xml +++ b/example/android/app/src/main/res/values-night/styles.xml @@ -1,7 +1,7 @@ - diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml index cb1ef88..d5c751c 100644 --- a/example/android/app/src/main/res/values/styles.xml +++ b/example/android/app/src/main/res/values/styles.xml @@ -1,7 +1,7 @@ - From ac4294b5b2acc58c02d7cd605ffa45c3ef8934ad Mon Sep 17 00:00:00 2001 From: Anton Stefer Date: Wed, 4 Feb 2026 15:43:39 +0100 Subject: [PATCH 05/20] feat(example): Add animation state change logging --- example/lib/main.dart | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 58587b9..13b6496 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -112,9 +112,14 @@ class _MyAppState extends State { void onViewCreated(LottieController controller) { this.controller = controller; - // Listen for when the playback completes - controller.onPlayFinished.listen((bool animationFinished) { - print("Playback complete. Was Animation Finished? $animationFinished"); + // Listen for all state changes (loaded, started, finished, cancelled) + controller.onStateChanged.listen((state) { + print("Animation state changed: $state"); + if (state == LottieAnimationState.finished) { + print("Playback complete. Was Animation Finished? true"); + } else if (state == LottieAnimationState.cancelled) { + print("Playback complete. Was Animation Finished? false"); + } }); } From 6f12b170fe5c181395f223d1eb62d57897442299 Mon Sep 17 00:00:00 2001 From: Anton Stefer Date: Wed, 4 Feb 2026 15:44:44 +0100 Subject: [PATCH 06/20] feat: Add runtime animation replacement methods --- .../de/lotum/lottie_native/LottieView.kt | 21 ++++++++++++++ ios/Classes/LottieView.swift | 28 +++++++++++++++++++ lib/src/lottie_controller.dart | 12 ++++++++ 3 files changed, 61 insertions(+) diff --git a/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt b/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt index ab04099..7681844 100644 --- a/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt +++ b/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt @@ -170,6 +170,26 @@ class LottieView internal constructor( setValue(type, value, keyPath) result.success(null) } + "setAnimationFromUrl" -> { + animationView.cancelAnimation() + animationView.progress = 0f + animationView.setAnimationFromUrl(args["url"] as String) + result.success(null) + } + "setAnimationFromAsset" -> { + animationView.cancelAnimation() + animationView.progress = 0f + val loader = FlutterInjector.instance().flutterLoader() + val key = loader.getLookupKeyForAsset(args["filePath"] as String) + animationView.setAnimation(key) + result.success(null) + } + "setAnimationFromJson" -> { + animationView.cancelAnimation() + animationView.progress = 0f + animationView.setAnimationFromJson(args["json"] as String, null) + result.success(null) + } else -> result.notImplemented() } } @@ -181,6 +201,7 @@ class LottieView internal constructor( override fun onCancel(o: Any?) {} override fun onCompositionLoaded(composition: LottieComposition?) { + maxFrame = composition?.endFrame ?: 0f onStateChangeEventSink?.success("loaded") } diff --git a/ios/Classes/LottieView.swift b/ios/Classes/LottieView.swift index 96d2576..df3be04 100644 --- a/ios/Classes/LottieView.swift +++ b/ios/Classes/LottieView.swift @@ -194,6 +194,34 @@ public class LottieView: NSObject, FlutterPlatformView, FlutterStreamHandler { setValue(type: type, value: value, keyPath: keyPath) result(nil) break + case "setAnimationFromUrl": + animationView.stop() + animationView.currentProgress = 0 + let url = props["url"] as! String + LottieAnimation.loadedFrom(url: URL(string: url)!, closure: { animation in + self.animationView.animation = animation + self.updateState(state: "loaded") + }, animationCache: nil) + result(nil) + break + case "setAnimationFromAsset": + animationView.stop() + animationView.currentProgress = 0 + let filePath = props["filePath"] as! String + let key = registrar.lookupKey(forAsset: filePath) + let path = Bundle.main.path(forResource: key, ofType: nil) + animationView.animation = LottieAnimation.filepath(path!) + updateState(state: "loaded") + result(nil) + break + case "setAnimationFromJson": + animationView.stop() + animationView.currentProgress = 0 + let json = props["json"] as! String + animationView.animation = try? LottieAnimation.from(data: Data(json.utf8)) + updateState(state: "loaded") + result(nil) + break default: result(FlutterMethodNotImplemented) break diff --git a/lib/src/lottie_controller.dart b/lib/src/lottie_controller.dart index 59ba1e0..48d0ded 100644 --- a/lib/src/lottie_controller.dart +++ b/lib/src/lottie_controller.dart @@ -64,6 +64,18 @@ class LottieController { return _channel.invokeMethod('resume'); } + Future setAnimationFromUrl(String url) async { + return _channel.invokeMethod('setAnimationFromUrl', {"url": url}); + } + + Future setAnimationFromAsset(String filePath) async { + return _channel.invokeMethod('setAnimationFromAsset', {"filePath": filePath}); + } + + Future setAnimationFromJson(String json) async { + return _channel.invokeMethod('setAnimationFromJson', {"json": json}); + } + Future setAnimationSpeed(double speed) async { return _channel .invokeMethod('setAnimationSpeed', {"speed": speed.clamp(0.0, 1.0)}); From d1e50405623569f017fbdaad97332ab22ffe211b Mon Sep 17 00:00:00 2001 From: Anton Stefer Date: Wed, 4 Feb 2026 15:45:11 +0100 Subject: [PATCH 07/20] feat(example): Add test buttons for animation swap --- example/lib/main.dart | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/example/lib/main.dart b/example/lib/main.dart index 13b6496..833a4ad 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -69,6 +69,28 @@ class _MyAppState extends State { controller?.resume(); }, ), + TextButton( + child: Text("Swap to Hamburger (URL)"), + onPressed: () { + controller?.setAnimationFromUrl( + 'https://raw.githubusercontent.com/airbnb/lottie-ios/master/Tests/Samples/HamburgerArrow.json', + ); + }, + ), + TextButton( + child: Text("Swap to Watermelon (URL)"), + onPressed: () { + controller?.setAnimationFromUrl( + 'https://raw.githubusercontent.com/airbnb/lottie-ios/master/Tests/Samples/Watermelon.json', + ); + }, + ), + TextButton( + child: Text("Swap to Asset"), + onPressed: () { + controller?.setAnimationFromAsset('animations/newAnimation.json'); + }, + ), Text("From File"), Container( child: SizedBox( From 0394f35d98b358bd10156e40a63413b352aa220a Mon Sep 17 00:00:00 2001 From: Anton Stefer Date: Wed, 4 Feb 2026 15:46:59 +0100 Subject: [PATCH 08/20] fix(android): Switch to hybrid composition to fix ImageReader warnings --- lib/src/lottie_view.dart | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/lib/src/lottie_view.dart b/lib/src/lottie_view.dart index 27537f5..073bd66 100644 --- a/lib/src/lottie_view.dart +++ b/lib/src/lottie_view.dart @@ -1,7 +1,9 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'lottie_controller.dart'; @@ -71,11 +73,28 @@ class _LottieViewState extends State { switch (defaultTargetPlatform) { case TargetPlatform.android: - return AndroidView( + return PlatformViewLink( viewType: viewType, - creationParams: creationParams, - creationParamsCodec: StandardMessageCodec(), - onPlatformViewCreated: onPlatformViewCreated, + surfaceFactory: (context, controller) { + return AndroidViewSurface( + controller: controller as AndroidViewController, + gestureRecognizers: const >{}, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + ); + }, + onCreatePlatformView: (params) { + return PlatformViewsService.initExpensiveAndroidView( + id: params.id, + viewType: viewType, + layoutDirection: TextDirection.ltr, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + onFocus: () => params.onFocusChanged(true), + ) + ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) + ..addOnPlatformViewCreatedListener(onPlatformViewCreated) + ..create(); + }, ); case TargetPlatform.iOS: From 72958815ada26a0458e3bdf26afcba25865e2272 Mon Sep 17 00:00:00 2001 From: Anton Stefer Date: Wed, 4 Feb 2026 16:08:36 +0100 Subject: [PATCH 09/20] fix(android): Move failure listener to init for all animation sources --- .../src/main/kotlin/de/lotum/lottie_native/LottieView.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt b/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt index 7681844..00f7433 100644 --- a/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt +++ b/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt @@ -38,6 +38,9 @@ class LottieView internal constructor( init { animationView.scaleType = ImageView.ScaleType.CENTER_INSIDE + animationView.setFailureListener { + Log.e("lottie_native", "Failed to load animation.", it) + } channel.setMethodCallHandler(this) onStateChangeEventChannel.setStreamHandler(this) @@ -45,9 +48,6 @@ class LottieView internal constructor( @Suppress("UNCHECKED_CAST", "NAME_SHADOWING") val args = args as Map if (args["url"] != null) { - animationView.setFailureListener { - Log.e("lottie_native", "Failed to set animation from URL.", it) - } animationView.setAnimationFromUrl(args["url"] as String) } if (args["filePath"] != null) { From a769899804ed2b2edceb163c6485b8de4c196982 Mon Sep 17 00:00:00 2001 From: Anton Stefer Date: Wed, 4 Feb 2026 16:10:48 +0100 Subject: [PATCH 10/20] style: Fix Dart formatting --- example/lib/main.dart | 3 ++- lib/src/lottie_controller.dart | 3 ++- lib/src/lottie_view.dart | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 833a4ad..5dce138 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -88,7 +88,8 @@ class _MyAppState extends State { TextButton( child: Text("Swap to Asset"), onPressed: () { - controller?.setAnimationFromAsset('animations/newAnimation.json'); + controller + ?.setAnimationFromAsset('animations/newAnimation.json'); }, ), Text("From File"), diff --git a/lib/src/lottie_controller.dart b/lib/src/lottie_controller.dart index 48d0ded..8e43ba5 100644 --- a/lib/src/lottie_controller.dart +++ b/lib/src/lottie_controller.dart @@ -69,7 +69,8 @@ class LottieController { } Future setAnimationFromAsset(String filePath) async { - return _channel.invokeMethod('setAnimationFromAsset', {"filePath": filePath}); + return _channel + .invokeMethod('setAnimationFromAsset', {"filePath": filePath}); } Future setAnimationFromJson(String json) async { diff --git a/lib/src/lottie_view.dart b/lib/src/lottie_view.dart index 073bd66..2784fb8 100644 --- a/lib/src/lottie_view.dart +++ b/lib/src/lottie_view.dart @@ -78,7 +78,8 @@ class _LottieViewState extends State { surfaceFactory: (context, controller) { return AndroidViewSurface( controller: controller as AndroidViewController, - gestureRecognizers: const >{}, + gestureRecognizers: const >{}, hitTestBehavior: PlatformViewHitTestBehavior.opaque, ); }, From 508975d02b437d840055475548bf9af4bd98819c Mon Sep 17 00:00:00 2001 From: Anton Stefer Date: Wed, 4 Feb 2026 16:13:58 +0100 Subject: [PATCH 11/20] fix: Replace deprecated Color.value with toARGB32() --- lib/src/lot_values/lot_color_value.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/lot_values/lot_color_value.dart b/lib/src/lot_values/lot_color_value.dart index aa7def9..e865d95 100644 --- a/lib/src/lot_values/lot_color_value.dart +++ b/lib/src/lot_values/lot_color_value.dart @@ -14,7 +14,7 @@ class LOTColorValue extends LOTValue { } String get value { - return '0x${_value.value.toRadixString(16).padLeft(8, '0')}'; + return '0x${_value.toARGB32().toRadixString(16).padLeft(8, '0')}'; } String get type { From e820fed85e9f3834517c15edee174c776bcf55ee Mon Sep 17 00:00:00 2001 From: Anton Stefer Date: Wed, 4 Feb 2026 17:49:18 +0100 Subject: [PATCH 12/20] chore: Upgrade native Lottie libraries and appcompat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lottie-android 6.3.0 → 6.7.1 - lottie-ios ~> 4.4.3 → ~> 4.6.0 - appcompat 1.6.1 → 1.7.1 Co-Authored-By: Claude Opus 4.5 --- android/build.gradle | 4 ++-- example/ios/Podfile.lock | 8 ++++---- ios/lottie_native.podspec | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 331610d..c66bcfd 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -49,6 +49,6 @@ android { } dependencies { - implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.airbnb.android:lottie:6.3.0' + implementation 'androidx.appcompat:appcompat:1.7.1' + implementation 'com.airbnb.android:lottie:6.7.1' } diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 2b8c18f..aeb0f97 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,9 +1,9 @@ PODS: - Flutter (1.0.0) - - lottie-ios (4.4.3) + - lottie-ios (4.6.0) - lottie_native (0.0.1): - Flutter - - lottie-ios (~> 4.4.3) + - lottie-ios (~> 4.6.0) DEPENDENCIES: - Flutter (from `Flutter`) @@ -21,8 +21,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - lottie-ios: fcb5e73e17ba4c983140b7d21095c834b3087418 - lottie_native: 4f3a9ef106799160946396cf50746ad169211e23 + lottie-ios: 8f959969761e9c45d70353667d00af0e5b9cadb3 + lottie_native: 7d45b3b30be9496324cd7a1abd5c70679e08d3b0 PODFILE CHECKSUM: d2243213672c3c48aae53c36642ba411a6be7309 diff --git a/ios/lottie_native.podspec b/ios/lottie_native.podspec index 039777b..9388d4d 100644 --- a/ios/lottie_native.podspec +++ b/ios/lottie_native.podspec @@ -14,7 +14,7 @@ Pod::Spec.new do |s| s.source_files = 'Classes/**/*' s.dependency 'Flutter' - s.dependency 'lottie-ios', '~> 4.4.3' + s.dependency 'lottie-ios', '~> 4.6.0' s.platform = :ios, '13.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } From 53d13117547ffbd6b51328fedca0f633238ec41a Mon Sep 17 00:00:00 2001 From: Anton Stefer Date: Wed, 4 Feb 2026 18:00:41 +0100 Subject: [PATCH 13/20] perf(android): Use texture layer hybrid composition for better performance --- lib/src/lottie_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/lottie_view.dart b/lib/src/lottie_view.dart index 2784fb8..85b1870 100644 --- a/lib/src/lottie_view.dart +++ b/lib/src/lottie_view.dart @@ -84,7 +84,7 @@ class _LottieViewState extends State { ); }, onCreatePlatformView: (params) { - return PlatformViewsService.initExpensiveAndroidView( + return PlatformViewsService.initSurfaceAndroidView( id: params.id, viewType: viewType, layoutDirection: TextDirection.ltr, From c8a077c7357e82aec6a8895daed90c9a8e43e3e7 Mon Sep 17 00:00:00 2001 From: Anton Stefer Date: Wed, 4 Feb 2026 18:09:32 +0100 Subject: [PATCH 14/20] perf(android): Enable async updates for better rendering performance --- android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt b/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt index 00f7433..573ce03 100644 --- a/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt +++ b/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt @@ -6,6 +6,7 @@ import android.content.Context import android.graphics.Color import android.view.View import android.widget.ImageView +import com.airbnb.lottie.AsyncUpdates import com.airbnb.lottie.LottieAnimationView import com.airbnb.lottie.LottieComposition import com.airbnb.lottie.LottieDrawable @@ -38,6 +39,7 @@ class LottieView internal constructor( init { animationView.scaleType = ImageView.ScaleType.CENTER_INSIDE + animationView.setAsyncUpdates(AsyncUpdates.ENABLED) animationView.setFailureListener { Log.e("lottie_native", "Failed to load animation.", it) } From df616714ae087d7c2217e8fc50e7a14fd3810e29 Mon Sep 17 00:00:00 2001 From: Anton Stefer Date: Thu, 5 Feb 2026 13:58:41 +0100 Subject: [PATCH 15/20] perf(android): Add safe performance optimizations and simplify platform view --- .../de/lotum/lottie_native/LottieView.kt | 1 + lib/src/lottie_view.dart | 28 +++---------------- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt b/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt index 573ce03..d1752aa 100644 --- a/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt +++ b/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt @@ -40,6 +40,7 @@ class LottieView internal constructor( init { animationView.scaleType = ImageView.ScaleType.CENTER_INSIDE animationView.setAsyncUpdates(AsyncUpdates.ENABLED) + animationView.setClipToCompositionBounds(true) animationView.setFailureListener { Log.e("lottie_native", "Failed to load animation.", it) } diff --git a/lib/src/lottie_view.dart b/lib/src/lottie_view.dart index 85b1870..ee203d2 100644 --- a/lib/src/lottie_view.dart +++ b/lib/src/lottie_view.dart @@ -1,9 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'lottie_controller.dart'; @@ -73,29 +71,11 @@ class _LottieViewState extends State { switch (defaultTargetPlatform) { case TargetPlatform.android: - return PlatformViewLink( + return AndroidView( viewType: viewType, - surfaceFactory: (context, controller) { - return AndroidViewSurface( - controller: controller as AndroidViewController, - gestureRecognizers: const >{}, - hitTestBehavior: PlatformViewHitTestBehavior.opaque, - ); - }, - onCreatePlatformView: (params) { - return PlatformViewsService.initSurfaceAndroidView( - id: params.id, - viewType: viewType, - layoutDirection: TextDirection.ltr, - creationParams: creationParams, - creationParamsCodec: const StandardMessageCodec(), - onFocus: () => params.onFocusChanged(true), - ) - ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) - ..addOnPlatformViewCreatedListener(onPlatformViewCreated) - ..create(); - }, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: onPlatformViewCreated, ); case TargetPlatform.iOS: From d0ae2c4ae1a9479ddb8a8d7f991ddfcc8e900ad6 Mon Sep 17 00:00:00 2001 From: Anton Stefer Date: Fri, 6 Feb 2026 16:10:32 +0100 Subject: [PATCH 16/20] docs: Prepare changelog for 0.2.0 release --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b48a82a..c788de0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 0.2.0 + +- **FEAT**: Add runtime animation replacement methods (`setAnimationFromURL`, `setAnimationFromAsset`, `setAnimationFromJson`) on `LottieController` +- **PERF**: Android: Enable async updates and simplify platform view for better performance +- **FIX**: Replace deprecated `Color.value` with `toARGB32()` +- **FIX**: Android: Move failure listener to init for all animation sources +- **BUILD**: Upgrade Kotlin to 2.1.0, AGP to 8.7.0, Java to 17 +- **BUILD**: Upgrade lottie-android 6.3.0 → 6.7.1 +- **BUILD**: Upgrade lottie-ios ~> 4.4.3 → ~> 4.6.0 +- **BUILD**: Upgrade appcompat 1.6.1 → 1.7.1 + ## 0.1.4 - **FEAT**: Provide animation state changes as stream to lottie controller From ff65a146105c051b61befa326ad898105e2a7aa4 Mon Sep 17 00:00:00 2001 From: Gabriel Terwesten Date: Wed, 8 Apr 2026 07:41:23 +0200 Subject: [PATCH 17/20] chore: update example app setup and repo docs --- .gitignore | 3 +- CLAUDE.md | 34 +++++-- example/ios/Flutter/AppFrameworkInfo.plist | 2 - example/ios/Runner/AppDelegate.swift | 9 +- example/ios/Runner/Info.plist | 105 ++++++++++++--------- 5 files changed, 95 insertions(+), 58 deletions(-) diff --git a/.gitignore b/.gitignore index 3ee1096..9545da8 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ build/ example/ios/Flutter/Flutter.podspec flutter_export_environment.sh -pubspec.lock \ No newline at end of file +pubspec.lock +pubspec_overrides.yaml \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 25faa2e..aba4b07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,13 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to Claude Code (claude.ai/code) when working with +code in this repository. ## Project Overview -Flutter plugin that wraps native Lottie animation libraries (lottie-ios and lottie-android) to render animations using platform-specific implementations. Forked from flutter_lottie. +Flutter plugin that wraps native Lottie animation libraries (lottie-ios and +lottie-android) to render animations using platform-specific implementations. +Forked from flutter_lottie. ## Common Commands @@ -30,30 +33,41 @@ melos run format:dart ### Platform Channel Communication The plugin uses Flutter's Method Channel and Event Channel pattern: -- **Method Channels** (`de.lotum/lottie_native_[id]`): Send commands to native (play, pause, stop, etc.) -- **Event Channels** (`de.lotum/lottie_native_state_[id]`): Stream animation state changes (loaded, started, finished, cancelled) + +- **Method Channels** (`de.lotum/lottie_native_[id]`): Send commands to native + (play, pause, stop, etc.) +- **Event Channels** (`de.lotum/lottie_native_state_[id]`): Stream animation + state changes (loaded, started, finished, cancelled) Each LottieView instance gets unique channels using its viewId. ### Key Components **Dart (lib/src/):** -- `LottieView` - Widget with factory constructors: `fromURL`, `fromAsset`, `fromJson` -- `LottieController` - Animation lifecycle management, exposes play/pause/stop and state streams -- `LOTValue` - Type system for dynamic animation properties (LOTColorValue, LOTOpacityValue) + +- `LottieView` - Widget with factory constructors: `fromURL`, `fromAsset`, + `fromJson` +- `LottieController` - Animation lifecycle management, exposes play/pause/stop + and state streams +- `LOTValue` - Type system for dynamic animation properties (LOTColorValue, + LOTOpacityValue) **Android (android/src/main/kotlin/):** + - `LottieNativePlugin` - Plugin entry point - `LottieView` - Wraps LottieAnimationView, handles MethodCall routing **iOS (ios/Classes/):** + - `LottieNativePlugin` - Plugin entry point - `LottieView` - Wraps LottieAnimationView, implements FlutterStreamHandler ### Native Dependencies -- Android: `com.airbnb.android:lottie:6.3.0` -- iOS: `lottie-ios ~> 4.4.3` (requires iOS 13.0+) + +- Android: `com.airbnb.android:lottie` +- iOS: `lottie-ios` ## Monorepo Structure -Uses Melos to manage root plugin + example app. The example app in `example/` contains sample animations and demonstrates the API. \ No newline at end of file +Uses Melos to manage root plugin + example app. The example app in `example/` +contains sample animations and demonstrates the API. diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf7..391a902 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index b636303..c30b367 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -1,13 +1,16 @@ -import UIKit import Flutter +import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index 864d618..9e3aa4a 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -1,51 +1,72 @@ + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + lottie_native_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest - CFBundleDevelopmentRegion - en - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - lottie_native_example - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance + UIApplicationSupportsMultipleScenes - io.flutter.embedded_views_preview - - CADisableMinimumFrameDurationOnPhone - + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + io.flutter.embedded_views_preview + From 37d5fb9ffd1cc7fb5b9aafce59a26c9cdf970099 Mon Sep 17 00:00:00 2001 From: Gabriel Terwesten Date: Wed, 8 Apr 2026 07:45:36 +0200 Subject: [PATCH 18/20] fix: validate dynamic value updates --- CHANGELOG.md | 1 + .../de/lotum/lottie_native/LottieView.kt | 80 +++++++++++++++---- ios/Classes/LottieView.swift | 73 ++++++++++++++--- 3 files changed, 126 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c788de0..17dd2ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - **PERF**: Android: Enable async updates and simplify platform view for better performance - **FIX**: Replace deprecated `Color.value` with `toARGB32()` - **FIX**: Android: Move failure listener to init for all animation sources +- **FIX**: Validate dynamic value updates on iOS and Android to avoid crashes on malformed color or opacity inputs - **BUILD**: Upgrade Kotlin to 2.1.0, AGP to 8.7.0, Java to 17 - **BUILD**: Upgrade lottie-android 6.3.0 → 6.7.1 - **BUILD**: Upgrade lottie-ios ~> 4.4.3 → ~> 4.6.0 diff --git a/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt b/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt index d1752aa..cbf1371 100644 --- a/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt +++ b/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt @@ -167,11 +167,25 @@ class LottieView internal constructor( "getLoopAnimation" -> result.success(animationView.repeatCount == LottieDrawable.INFINITE) "getAutoReverseAnimation" -> result.success(animationView.repeatMode == LottieDrawable.REVERSE) "setValue" -> { - val value = args["value"] as String - val keyPath = args["keyPath"] as String - val type = args["type"] as String - setValue(type, value, keyPath) - result.success(null) + val value = args["value"] as? String + val keyPath = args["keyPath"] as? String + val type = args["type"] as? String + + if (value == null || keyPath == null || type == null) { + result.error( + "invalid_arguments", + "setValue expects string arguments for value, type, and keyPath.", + args, + ) + return + } + + val error = setValue(type, value, keyPath) + if (error == null) { + result.success(null) + } else { + result.error(error.code, error.message, error.details) + } } "setAnimationFromUrl" -> { animationView.cancelAnimation() @@ -222,27 +236,63 @@ class LottieView internal constructor( override fun onAnimationRepeat(animation: Animator) {} - private fun setValue(type: String, value: String, keyPath: String) { + private fun setValue(type: String, value: String, keyPath: String): MethodCallError? { val keyPathSegments = keyPath.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() val parsedKeyPath = KeyPath(*keyPathSegments) when (type) { "LOTColorValue" -> { - val callbackValue = LottieValueCallback(convertColor(value)) + val color = convertColor(value) + ?: return MethodCallError( + "invalid_color_value", + "Expected a color value formatted like 0xff0000ff or #ff0000ff.", + value, + ) + val callbackValue = LottieValueCallback(color) animationView.addValueCallback(parsedKeyPath, LottieProperty.COLOR, callbackValue) } "LOTOpacityValue" -> { - val opacity = value.toFloat() * 100 - val callbackValue = LottieValueCallback(opacity.roundToInt()) + val opacity = value.toFloatOrNull() + ?: return MethodCallError( + "invalid_opacity_value", + "Expected opacity as a decimal string, for example 0.1.", + value, + ) + val callbackValue = LottieValueCallback((opacity * 100).roundToInt()) animationView.addValueCallback(parsedKeyPath, LottieProperty.OPACITY, callbackValue) } + else -> + return MethodCallError( + "unsupported_value_type", + "Unsupported value type: $type", + type, + ) } + + return null } - private fun convertColor(value: String): Int { - val alpha = value.substring(2,4).toInt(16) - val red = value.substring(4, 6).toInt(16) - val green = value.substring(6, 8).toInt(16) - val blue = value.substring(8, 10).toInt(16) + private fun convertColor(value: String): Int? { + val sanitizedValue = + when { + value.startsWith("0x", ignoreCase = true) -> value.drop(2) + value.startsWith("#") -> value.drop(1) + else -> value + } + + if (sanitizedValue.length != 8) { + return null + } + + val alpha = sanitizedValue.substring(0, 2).toIntOrNull(16) ?: return null + val red = sanitizedValue.substring(2, 4).toIntOrNull(16) ?: return null + val green = sanitizedValue.substring(4, 6).toIntOrNull(16) ?: return null + val blue = sanitizedValue.substring(6, 8).toIntOrNull(16) ?: return null return Color.argb(alpha, red, green, blue) } -} \ No newline at end of file + + private data class MethodCallError( + val code: String, + val message: String, + val details: Any?, + ) +} diff --git a/ios/Classes/LottieView.swift b/ios/Classes/LottieView.swift index df3be04..c10c481 100644 --- a/ios/Classes/LottieView.swift +++ b/ios/Classes/LottieView.swift @@ -188,11 +188,26 @@ public class LottieView: NSObject, FlutterPlatformView, FlutterStreamHandler { result(animationView.loopMode) break case "setValue": - let value = props["value"] as! String - let keyPath = props["keyPath"] as! String - let type = props["type"] as! String - setValue(type: type, value: value, keyPath: keyPath) - result(nil) + guard + let value = props["value"] as? String, + let keyPath = props["keyPath"] as? String, + let type = props["type"] as? String + else { + result( + FlutterError( + code: "invalid_arguments", + message: "setValue expects string arguments for value, type, and keyPath.", + details: props + ) + ) + return + } + + if let error = setValue(type: type, value: value, keyPath: keyPath) { + result(error) + } else { + result(nil) + } break case "setAnimationFromUrl": animationView.stop() @@ -240,20 +255,52 @@ public class LottieView: NSObject, FlutterPlatformView, FlutterStreamHandler { return nil } - func setValue(type: String, value: String, keyPath: String) { + func setValue(type: String, value: String, keyPath: String) -> FlutterError? { switch type { case "LOTColorValue": - let hexColor = UInt32(value.dropFirst(2), radix: 16) - let value = ColorValueProvider(hexToColor(hex8: hexColor!)) + guard let hexColor = parseColorValue(value) else { + return FlutterError( + code: "invalid_color_value", + message: "Expected a color value formatted like 0xff0000ff or #ff0000ff.", + details: value + ) + } + + let valueProvider = ColorValueProvider(hexToColor(hex8: hexColor)) let keypath = AnimationKeypath(keypath: keyPath + ".Color") - animationView.setValueProvider(value, keypath: keypath) + animationView.setValueProvider(valueProvider, keypath: keypath) case "LOTOpacityValue": - let number = NumberFormatter().number(from: value)! - let value = FloatValueProvider(CGFloat(truncating: number) * 100) + guard let opacity = Double(value) else { + return FlutterError( + code: "invalid_opacity_value", + message: "Expected opacity as a decimal string, for example 0.1.", + details: value + ) + } + + let valueProvider = FloatValueProvider(CGFloat(opacity) * 100) let keypath = AnimationKeypath(keypath: keyPath + ".Opacity") - animationView.setValueProvider(value, keypath: keypath) + animationView.setValueProvider(valueProvider, keypath: keypath) default: - break + return FlutterError( + code: "unsupported_value_type", + message: "Unsupported value type: \(type)", + details: type + ) + } + + return nil + } + + private func parseColorValue(_ value: String) -> UInt32? { + if value.hasPrefix("0x") || value.hasPrefix("0X") { + return UInt32(value.dropFirst(2), radix: 16) } + + if value.hasPrefix("#") { + return UInt32(value.dropFirst(), radix: 16) + } + + return UInt32(value, radix: 16) } } From 1b81059fcf4439b514c9acb686c7d0d357fe2ff8 Mon Sep 17 00:00:00 2001 From: Gabriel Terwesten Date: Wed, 8 Apr 2026 08:01:46 +0200 Subject: [PATCH 19/20] Unify native animation loading paths --- .../de/lotum/lottie_native/LottieView.kt | 184 +++++++++++--- ios/Classes/LottieView.swift | 231 ++++++++++++++---- 2 files changed, 333 insertions(+), 82 deletions(-) diff --git a/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt b/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt index cbf1371..abf8677 100644 --- a/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt +++ b/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt @@ -9,6 +9,7 @@ import android.widget.ImageView import com.airbnb.lottie.AsyncUpdates import com.airbnb.lottie.LottieAnimationView import com.airbnb.lottie.LottieComposition +import com.airbnb.lottie.LottieCompositionFactory import com.airbnb.lottie.LottieDrawable import com.airbnb.lottie.LottieOnCompositionLoadedListener import com.airbnb.lottie.LottieProperty @@ -36,6 +37,7 @@ class LottieView internal constructor( private val onStateChangeEventChannel = EventChannel(binaryMessenger, "de.lotum/lottie_native_state_$id") private var onStateChangeEventSink: EventSink? = null private var maxFrame = 0f + private var animationLoadRequestId = 0 init { animationView.scaleType = ImageView.ScaleType.CENTER_INSIDE @@ -47,23 +49,21 @@ class LottieView internal constructor( channel.setMethodCallHandler(this) onStateChangeEventChannel.setStreamHandler(this) + animationView.addAnimatorListener(this) + animationView.addLottieOnCompositionLoadedListener(this) @Suppress("UNCHECKED_CAST", "NAME_SHADOWING") val args = args as Map - - if (args["url"] != null) { - animationView.setAnimationFromUrl(args["url"] as String) - } - if (args["filePath"] != null) { - val loader = FlutterInjector.instance().flutterLoader() - val key = loader.getLookupKeyForAsset(args["filePath"] as String) - animationView.setAnimation(key) - } - if (args["json"] != null) { - animationView.setAnimationFromJson(args["json"] as String, null) - } val loop: Boolean = if (args["loop"] != null) args["loop"] as Boolean else false val reverse: Boolean = if (args["reverse"] != null) args["reverse"] as Boolean else false val autoPlay: Boolean = if (args["autoPlay"] != null) args["autoPlay"] as Boolean else false + + if (args["url"] != null) { + loadAnimationFromUrl(args["url"] as String, autoPlay = autoPlay) + } else if (args["filePath"] != null) { + loadAnimationFromAsset(args["filePath"] as String, autoPlay = autoPlay) + } else if (args["json"] != null) { + loadAnimationFromJson(args["json"] as String, autoPlay = autoPlay) + } animationView.repeatCount = if (loop) -1 else 0 maxFrame = animationView.maxFrame if (reverse) { @@ -71,12 +71,6 @@ class LottieView internal constructor( } else { animationView.repeatMode = LottieDrawable.RESTART } - if (autoPlay) { - animationView.playAnimation() - } - - animationView.addAnimatorListener(this) - animationView.addLottieOnCompositionLoadedListener(this); } override fun getView(): View { @@ -187,26 +181,9 @@ class LottieView internal constructor( result.error(error.code, error.message, error.details) } } - "setAnimationFromUrl" -> { - animationView.cancelAnimation() - animationView.progress = 0f - animationView.setAnimationFromUrl(args["url"] as String) - result.success(null) - } - "setAnimationFromAsset" -> { - animationView.cancelAnimation() - animationView.progress = 0f - val loader = FlutterInjector.instance().flutterLoader() - val key = loader.getLookupKeyForAsset(args["filePath"] as String) - animationView.setAnimation(key) - result.success(null) - } - "setAnimationFromJson" -> { - animationView.cancelAnimation() - animationView.progress = 0f - animationView.setAnimationFromJson(args["json"] as String, null) - result.success(null) - } + "setAnimationFromUrl" -> setAnimationFromUrl(args, result) + "setAnimationFromAsset" -> setAnimationFromAsset(args, result) + "setAnimationFromJson" -> setAnimationFromJson(args, result) else -> result.notImplemented() } } @@ -236,6 +213,137 @@ class LottieView internal constructor( override fun onAnimationRepeat(animation: Animator) {} + private fun resetAnimationPlayback() { + animationView.cancelAnimation() + animationView.progress = 0f + } + + private fun nextAnimationLoadRequestId(): Int { + animationLoadRequestId += 1 + return animationLoadRequestId + } + + private fun invalidatePendingAnimationLoads() { + nextAnimationLoadRequestId() + } + + private fun setAnimationFromUrl(args: Map, result: MethodChannel.Result) { + val url = args["url"] as? String + + if (url == null) { + result.error( + "invalid_arguments", + "setAnimationFromUrl expects a string url argument.", + args, + ) + return + } + + resetAnimationPlayback() + loadAnimationFromUrl(url, result = result) + } + + private fun setAnimationFromAsset(args: Map, result: MethodChannel.Result) { + val filePath = args["filePath"] as? String + + if (filePath == null) { + result.error( + "invalid_arguments", + "setAnimationFromAsset expects a string filePath argument.", + args, + ) + return + } + + invalidatePendingAnimationLoads() + resetAnimationPlayback() + loadAnimationFromAsset(filePath) + result.success(null) + } + + private fun setAnimationFromJson(args: Map, result: MethodChannel.Result) { + val json = args["json"] as? String + + if (json == null) { + result.error( + "invalid_arguments", + "setAnimationFromJson expects a string json argument.", + args, + ) + return + } + + invalidatePendingAnimationLoads() + resetAnimationPlayback() + loadAnimationFromJson(json) + result.success(null) + } + + private fun loadAnimationFromUrl( + url: String, + autoPlay: Boolean = false, + result: MethodChannel.Result? = null, + ) { + val requestId = nextAnimationLoadRequestId() + + LottieCompositionFactory.fromUrl(animationView.context, url) + .addListener { composition -> + if (requestId != animationLoadRequestId) { + result?.success(null) + return@addListener + } + + maxFrame = composition.endFrame + animationView.setComposition(composition) + + if (autoPlay) { + animationView.playAnimation() + } + + result?.success(null) + } + .addFailureListener { error -> + if (requestId != animationLoadRequestId) { + result?.success(null) + return@addFailureListener + } + + Log.e("lottie_native", "Failed to load animation from URL.", error) + + if (result != null) { + result.error( + "animation_load_failed", + "Failed to load animation from URL.", + error.localizedMessage ?: url, + ) + } + } + } + + private fun loadAnimationFromAsset( + filePath: String, + autoPlay: Boolean = false, + ) { + val loader = FlutterInjector.instance().flutterLoader() + val key = loader.getLookupKeyForAsset(filePath) + animationView.setAnimation(key) + + if (autoPlay) { + animationView.playAnimation() + } + } + + private fun loadAnimationFromJson( + json: String, + autoPlay: Boolean = false, + ) { + animationView.setAnimationFromJson(json, null) + + if (autoPlay) { + animationView.playAnimation() + } + } + private fun setValue(type: String, value: String, keyPath: String): MethodCallError? { val keyPathSegments = keyPath.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() val parsedKeyPath = KeyPath(*keyPathSegments) diff --git a/ios/Classes/LottieView.swift b/ios/Classes/LottieView.swift index c10c481..a40a63e 100644 --- a/ios/Classes/LottieView.swift +++ b/ios/Classes/LottieView.swift @@ -11,6 +11,7 @@ public class LottieView: NSObject, FlutterPlatformView, FlutterStreamHandler { let registrar: FlutterPluginRegistrar let animationView: LottieAnimationView var eventSink: FlutterEventSink? + private var animationLoadToken = 0 init(_ frame: CGRect, viewId: Int64, args: Any?, registrar: FlutterPluginRegistrar) { self.frame = frame @@ -51,27 +52,12 @@ public class LottieView: NSObject, FlutterPlatformView, FlutterStreamHandler { animationView.loopMode = LottieLoopMode.autoReverse } - if url != nil { - LottieAnimation.loadedFrom( - url: URL(string: url!)!, - closure: { animation in - self.animationView.animation = animation - if autoPlay && animation != nil { - self.playAnimation() - } - }, - animationCache: nil - ) - } else if filePath != nil { - let key = registrar.lookupKey(forAsset: filePath!) - let path = Bundle.main.path(forResource: key, ofType: nil) - animationView.animation = LottieAnimation.filepath(path!) - } else if json != nil { - animationView.animation = try? LottieAnimation.from(data: Data(json!.utf8)) - } - - if autoPlay { - playAnimation() + if let url, let resolvedUrl = URL(string: url) { + loadAnimationFromUrl(resolvedUrl, autoPlay: autoPlay) + } else if let filePath { + _ = loadAnimationFromAsset(filePath, autoPlay: autoPlay) + } else if let json { + _ = loadAnimationFromJson(json, autoPlay: autoPlay) } } @@ -99,7 +85,7 @@ public class LottieView: NSObject, FlutterPlatformView, FlutterStreamHandler { } } - func methodCall(call: FlutterMethodCall, result: FlutterResult) { + func methodCall(call: FlutterMethodCall, result: @escaping FlutterResult) { let props = call.arguments as? [String: Any] ?? [String: Any]() switch call.method { @@ -210,32 +196,13 @@ public class LottieView: NSObject, FlutterPlatformView, FlutterStreamHandler { } break case "setAnimationFromUrl": - animationView.stop() - animationView.currentProgress = 0 - let url = props["url"] as! String - LottieAnimation.loadedFrom(url: URL(string: url)!, closure: { animation in - self.animationView.animation = animation - self.updateState(state: "loaded") - }, animationCache: nil) - result(nil) + setAnimationFromUrl(props, result: result) break case "setAnimationFromAsset": - animationView.stop() - animationView.currentProgress = 0 - let filePath = props["filePath"] as! String - let key = registrar.lookupKey(forAsset: filePath) - let path = Bundle.main.path(forResource: key, ofType: nil) - animationView.animation = LottieAnimation.filepath(path!) - updateState(state: "loaded") - result(nil) + setAnimationFromAsset(props, result: result) break case "setAnimationFromJson": - animationView.stop() - animationView.currentProgress = 0 - let json = props["json"] as! String - animationView.animation = try? LottieAnimation.from(data: Data(json.utf8)) - updateState(state: "loaded") - result(nil) + setAnimationFromJson(props, result: result) break default: result(FlutterMethodNotImplemented) @@ -303,4 +270,180 @@ public class LottieView: NSObject, FlutterPlatformView, FlutterStreamHandler { return UInt32(value, radix: 16) } + + private func resetAnimationPlayback() { + animationView.stop() + animationView.currentProgress = 0 + } + + private func nextAnimationLoadToken() -> Int { + animationLoadToken += 1 + return animationLoadToken + } + + private func invalidatePendingAnimationLoads() { + _ = nextAnimationLoadToken() + } + + private func setAnimationFromUrl( + _ props: [String: Any], + result: @escaping FlutterResult + ) { + guard + let urlString = props["url"] as? String, + let url = URL(string: urlString) + else { + result( + FlutterError( + code: "invalid_arguments", + message: "setAnimationFromUrl expects a valid string url argument.", + details: props + ) + ) + return + } + + resetAnimationPlayback() + loadAnimationFromUrl(url, result: result) + } + + private func setAnimationFromAsset( + _ props: [String: Any], + result: FlutterResult + ) { + guard let filePath = props["filePath"] as? String else { + result( + FlutterError( + code: "invalid_arguments", + message: "setAnimationFromAsset expects a string filePath argument.", + details: props + ) + ) + return + } + + invalidatePendingAnimationLoads() + resetAnimationPlayback() + guard loadAnimationFromAsset(filePath) else { + result( + FlutterError( + code: "animation_load_failed", + message: "Failed to load animation from asset.", + details: filePath + ) + ) + return + } + + result(nil) + } + + private func setAnimationFromJson( + _ props: [String: Any], + result: FlutterResult + ) { + guard let json = props["json"] as? String else { + result( + FlutterError( + code: "invalid_arguments", + message: "setAnimationFromJson expects a string json argument.", + details: props + ) + ) + return + } + + invalidatePendingAnimationLoads() + resetAnimationPlayback() + guard loadAnimationFromJson(json) else { + result( + FlutterError( + code: "animation_load_failed", + message: "Failed to parse animation JSON.", + details: nil + ) + ) + return + } + + result(nil) + } + + private func loadAnimationFromUrl( + _ url: URL, + autoPlay: Bool = false, + result: FlutterResult? = nil + ) { + let loadToken = nextAnimationLoadToken() + + LottieAnimation.loadedFrom( + url: url, + closure: { animation in + if loadToken != self.animationLoadToken { + result?(nil) + return + } + + guard let animation else { + result?( + FlutterError( + code: "animation_load_failed", + message: "Failed to load animation from URL.", + details: url.absoluteString + ) + ) + return + } + + self.animationView.animation = animation + + if autoPlay { + self.playAnimation() + } + + result?(nil) + }, + animationCache: nil + ) + } + + @discardableResult + private func loadAnimationFromAsset( + _ filePath: String, + autoPlay: Bool = false + ) -> Bool { + let key = registrar.lookupKey(forAsset: filePath) + guard + let path = Bundle.main.path(forResource: key, ofType: nil), + let animation = LottieAnimation.filepath(path) + else { + return false + } + + animationView.animation = animation + + if autoPlay { + playAnimation() + } + + return true + } + + @discardableResult + private func loadAnimationFromJson( + _ json: String, + autoPlay: Bool = false + ) -> Bool { + guard let animation = try? LottieAnimation.from(data: Data(json.utf8)) else { + return false + } + + animationView.animation = animation + + if autoPlay { + playAnimation() + } + + return true + } } From c78112693c688e1207dac6e724d9f2cdd7e40cab Mon Sep 17 00:00:00 2001 From: Gabriel Terwesten Date: Wed, 8 Apr 2026 08:07:23 +0200 Subject: [PATCH 20/20] Align native progress update behavior --- CHANGELOG.md | 1 + .../src/main/kotlin/de/lotum/lottie_native/LottieView.kt | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17dd2ba..02978f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - **FIX**: Replace deprecated `Color.value` with `toARGB32()` - **FIX**: Android: Move failure listener to init for all animation sources - **FIX**: Validate dynamic value updates on iOS and Android to avoid crashes on malformed color or opacity inputs +- **FIX**: Android: Align `setAnimationProgress` and `setProgressWithFrame` behavior with iOS - **BUILD**: Upgrade Kotlin to 2.1.0, AGP to 8.7.0, Java to 17 - **BUILD**: Upgrade lottie-android 6.3.0 → 6.7.1 - **BUILD**: Upgrade lottie-ios ~> 4.4.3 → ~> 4.6.0 diff --git a/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt b/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt index abf8677..e5a9c62 100644 --- a/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt +++ b/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt @@ -147,10 +147,16 @@ class LottieView internal constructor( result.success(null) } "setAnimationProgress" -> { + // Android keeps advancing after a progress seek if playback is still active. + // iOS' currentProgress setter already leaves the animation at the requested frame, + // so we only need to pause explicitly on Android to keep the API behavior aligned. + animationView.pauseAnimation() animationView.progress = (args["progress"] as Double).toFloat() result.success(null) } "setProgressWithFrame" -> { + // Apply the same alignment for frame-based updates. + animationView.pauseAnimation() animationView.frame = args["progress"] as Int result.success(null) }