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/CHANGELOG.md b/CHANGELOG.md index b48a82a..02978f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## 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 +- **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 +- **BUILD**: Upgrade appcompat 1.6.1 → 1.7.1 + ## 0.1.4 - **FEAT**: Provide animation state changes as stream to lottie controller diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..aba4b07 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# 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` +- 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. diff --git a/android/build.gradle b/android/build.gradle index c7fafcf..c66bcfd 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' @@ -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/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt b/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt index ab04099..e5a9c62 100644 --- a/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt +++ b/android/src/main/kotlin/de/lotum/lottie_native/LottieView.kt @@ -6,8 +6,10 @@ 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.LottieCompositionFactory import com.airbnb.lottie.LottieDrawable import com.airbnb.lottie.LottieOnCompositionLoadedListener import com.airbnb.lottie.LottieProperty @@ -35,32 +37,33 @@ 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 + animationView.setAsyncUpdates(AsyncUpdates.ENABLED) + animationView.setClipToCompositionBounds(true) + animationView.setFailureListener { + Log.e("lottie_native", "Failed to load animation.", it) + } 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.setFailureListener { - Log.e("lottie_native", "Failed to set animation from URL.", it) - } - 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) { @@ -68,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 { @@ -150,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) } @@ -164,12 +167,29 @@ 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" -> setAnimationFromUrl(args, result) + "setAnimationFromAsset" -> setAnimationFromAsset(args, result) + "setAnimationFromJson" -> setAnimationFromJson(args, result) 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") } @@ -198,27 +219,194 @@ class LottieView internal constructor( override fun onAnimationRepeat(animation: Animator) {} - private fun setValue(type: String, value: String, keyPath: String) { + 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) 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/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/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 @@ - 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..391a902 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 11.0 diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index ac021cf..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`) @@ -20,10 +20,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/lottie_native/ios" SPEC CHECKSUMS: - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - lottie-ios: fcb5e73e17ba4c983140b7d21095c834b3087418 - lottie_native: d7625e69e3104fa40a333ab2ed9ae77409b2777e + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + lottie-ios: 8f959969761e9c45d70353667d00af0e5b9cadb3 + lottie_native: 7d45b3b30be9496324cd7a1abd5c70679e08d3b0 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..c30b367 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -1,13 +1,16 @@ -import UIKit import Flutter +import UIKit -@UIApplicationMain -@objc class AppDelegate: FlutterAppDelegate { +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { 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) } + + 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 + diff --git a/example/lib/main.dart b/example/lib/main.dart index 58587b9..5dce138 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -69,6 +69,29 @@ 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( @@ -112,9 +135,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"); + } }); } diff --git a/ios/Classes/LottieView.swift b/ios/Classes/LottieView.swift index 96d2576..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 { @@ -188,11 +174,35 @@ 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": + setAnimationFromUrl(props, result: result) + break + case "setAnimationFromAsset": + setAnimationFromAsset(props, result: result) + break + case "setAnimationFromJson": + setAnimationFromJson(props, result: result) break default: result(FlutterMethodNotImplemented) @@ -212,20 +222,228 @@ 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) + } + + 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 } } 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' } 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 { diff --git a/lib/src/lottie_controller.dart b/lib/src/lottie_controller.dart index 59ba1e0..8e43ba5 100644 --- a/lib/src/lottie_controller.dart +++ b/lib/src/lottie_controller.dart @@ -64,6 +64,19 @@ 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)}); diff --git a/lib/src/lottie_view.dart b/lib/src/lottie_view.dart index 27537f5..ee203d2 100644 --- a/lib/src/lottie_view.dart +++ b/lib/src/lottie_view.dart @@ -74,7 +74,7 @@ class _LottieViewState extends State { return AndroidView( viewType: viewType, creationParams: creationParams, - creationParamsCodec: StandardMessageCodec(), + creationParamsCodec: const StandardMessageCodec(), onPlatformViewCreated: onPlatformViewCreated, );