diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml
index 25690b3..9dbdd02 100644
--- a/.github/workflows/flutter.yml
+++ b/.github/workflows/flutter.yml
@@ -1,19 +1,15 @@
name: unit test
-on: [push]
+on: [push, pull_request]
jobs:
- build:
-
+ test:
runs-on: ubuntu-latest
-
steps:
- - uses: actions/checkout@v1
- - uses: actions/setup-java@v1
- with:
- java-version: '12.x'
- - uses: subosito/flutter-action@v1
- with:
- channel: beta
- - run: flutter pub get
- - run: flutter test
+ - uses: actions/checkout@v4
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ - run: flutter pub get
+ - run: flutter analyze
+ - run: flutter test
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4575f67..b509f60 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,14 @@
+## 4.0.0
+
+* **BREAKING:** Use the standalone [`material_ui`](https://pub.dev/packages/material_ui) package instead of `package:flutter/material.dart`. Requires Flutter `>=3.44.0` and Dart `^3.12.0`.
+* Keep the animation duration in sync when `period` changes, and restart cleanly when `enabled` is toggled back on
+* Start infinite loops with `AnimationController.repeat()` instead of a first `forward()` cycle
+* Treat `direction` updates as paint work, not layout work
+* Expand widget tests for construction, animation, looping, and highlight geometry
+* Refresh README, example README, and add `docs/` for architecture and optimization notes
+* Remove leftover `lib/main.dart` counter app from the package
+
## 3.0.0
* upgrade sdk constraint to support Dart 3
diff --git a/README.md b/README.md
index 63be96f..158e5d4 100644
--- a/README.md
+++ b/README.md
@@ -1,38 +1,133 @@
# Shimmer
-[](https://pub.dartlang.org/packages/shimmer) 
+[](https://pub.dev/packages/shimmer)
+
-A package provides an easy way to add shimmer effect in Flutter project
+A lightweight Flutter widget that paints a moving highlight over placeholder
+UI. Typical uses are skeleton screens while data loads, and a sliding highlight
+on a call to action.
-## How to use
+## Install
+
+```yaml
+dependencies:
+ shimmer: ^4.0.0
+ material_ui: ^1.0.1
+```
+
+`shimmer` 4.0 uses Flutter's standalone [`material_ui`](https://pub.dev/packages/material_ui) package (Flutter 3.44+). Apps that still import `package:flutter/material.dart` can wrap those subtrees in `MaterialUiCompatibilityBridge`.
```dart
+import 'package:material_ui/material_ui.dart';
import 'package:shimmer/shimmer.dart';
+```
+
+## Usage
+
+### Skeleton placeholder
+
+`Shimmer.fromColors` is the usual constructor. Build the child from solid
+shapes (`Container`, `Row`, `Column`). The gradient replaces those colors;
+transparent pixels stay transparent.
+
+```dart
+Shimmer.fromColors(
+ baseColor: Colors.grey.shade300,
+ highlightColor: Colors.grey.shade100,
+ child: Column(
+ children: [
+ Container(height: 200, color: Colors.white),
+ const SizedBox(height: 16),
+ Container(height: 12, color: Colors.white),
+ const SizedBox(height: 8),
+ Container(height: 12, width: 200, color: Colors.white),
+ ],
+ ),
+);
+```
+Dark theme:
+
+```dart
+Shimmer.fromColors(
+ baseColor: Colors.grey.shade800,
+ highlightColor: Colors.grey.shade600,
+ child: placeholder,
+);
```
+### Custom gradient
+
+Use the default constructor when you need a `RadialGradient`, `SweepGradient`,
+or a `LinearGradient` that follows `Theme`.
+
```dart
-SizedBox(
- width: 200.0,
- height: 100.0,
- child: Shimmer.fromColors(
- baseColor: Colors.red,
- highlightColor: Colors.yellow,
- child: Text(
- 'Shimmer',
- textAlign: TextAlign.center,
- style: TextStyle(
- fontSize: 40.0,
- fontWeight:
- FontWeight.bold,
- ),
- ),
+Shimmer(
+ gradient: LinearGradient(
+ colors: [
+ Theme.of(context).colorScheme.surfaceContainerHighest,
+ Theme.of(context).colorScheme.surface,
+ Theme.of(context).colorScheme.surfaceContainerHighest,
+ ],
+ stops: const [0.35, 0.5, 0.65],
),
+ child: placeholder,
+);
+```
+
+### Direction, speed, and loops
+
+```dart
+Shimmer.fromColors(
+ direction: ShimmerDirection.rtl,
+ period: const Duration(milliseconds: 1200),
+ loop: 0, // 0 = forever
+ enabled: isLoading,
+ baseColor: Colors.grey.shade300,
+ highlightColor: Colors.grey.shade100,
+ child: placeholder,
);
+```
+
+| Parameter | Default | Role |
+|-------------|----------------|------|
+| `child` | required | Opaque area the highlight is blended onto |
+| `gradient` | required\* | Highlight colors (`fromColors` builds this for you) |
+| `direction` | `ltr` | `ltr`, `rtl`, `ttb`, `btt` |
+| `period` | `1500ms` | Duration of one pass |
+| `loop` | `0` | Passes before stopping; `0` repeats forever |
+| `enabled` | `true` | `false` pauses the animation |
+\*Required on `Shimmer(...)`. `Shimmer.fromColors` takes `baseColor` and
+`highlightColor` instead.
+
+## Performance
+
+- Wrap a **list of placeholders in one `Shimmer`**, not one `Shimmer` per row.
+- Keep `child` simple and static. Fancy widgets (images, text with decoration,
+ elevation) often look wrong because the shader replaces their colors.
+- Toggle `enabled` to `false` when loading finishes so the ticker stops.
+
+## Example
+
+The `example/` app shows a loading list and a “slide to unlock” highlight.
+From the repository root:
+
+```bash
+cd example && flutter run
```
+
+## How it works
+
+`Shimmer` drives an `AnimationController` and paints a `ShaderMaskLayer` over
+the child (`BlendMode.srcIn`). The highlight rectangle is three times the
+child size so the band can travel fully across the widget.
+
+Project internals, tests, and further optimization notes live in
+[`docs/overview.md`](docs/overview.md) and
+[`docs/optimization.md`](docs/optimization.md).
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 1c64a96..f491756 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -29,9 +29,15 @@ analyzer:
# Ignore analyzer hints for updating pubspecs when using Future or
# Stream and not importing dart:async
# Please see https://github.com/flutter/flutter/pull/24528 for details.
- sdk_version_async_exported_from_core: ignore
exclude:
- bin/cache/**
+ - build/**
+ - android/**
+ - ios/**
+ - web/**
+ - windows/**
+ - macos/**
+ - linux/**
linter:
rules:
@@ -52,7 +58,6 @@ linter:
- avoid_field_initializers_in_const_classes
- avoid_function_literals_in_foreach_calls
- avoid_init_to_null
- - avoid_null_checks_in_equality_operators
- avoid_private_typedef_functions
- avoid_relative_lib_imports
- avoid_renaming_method_parameters
@@ -87,12 +92,10 @@ linter:
- hash_and_equals
- implementation_imports
# - invariant_booleans # too many false positives: https://github.com/dart-lang/linter/issues/811
- - iterable_contains_unrelated_type
# - join_return_with_assignment # not yet tested
- library_names
- library_prefixes
# - lines_longer_than_80_chars # not yet tested
- - list_remove_unrelated_type
# - literal_only_boolean_expressions # too many false positives: https://github.com/dart-lang/sdk/issues/34181
- no_adjacent_strings_in_list
- no_duplicate_case_values
@@ -102,7 +105,6 @@ linter:
# - one_member_abstracts # too many false positives
# - only_throw_errors # https://github.com/flutter/flutter/issues/5792
- overridden_fields
- - package_api_docs
- package_names
- package_prefixed_library_names
# - parameter_assignments # we do this commonly
diff --git a/docs/lessons/material-ui-migration.md b/docs/lessons/material-ui-migration.md
new file mode 100644
index 0000000..ca2bad0
--- /dev/null
+++ b/docs/lessons/material-ui-migration.md
@@ -0,0 +1,18 @@
+# Lessons: material_ui migration
+
+## Add the package before trusting dart fix pubspec edits
+
+`dart fix --apply --code=migrate_design_widgets` rewrites imports correctly, but
+it may add `material_ui: any` to `example/pubspec.yaml`. Replace that with a
+semver range (`^1.0.1`) after `flutter pub add material_ui`.
+
+## Import order
+
+The fix inserts `package:material_ui/material_ui.dart` where
+`package:flutter/material.dart` was. Re-sort so `package:flutter/...` imports
+stay together above `material_ui` (`directives_ordering`).
+
+## Package major version
+
+Treat the move off SDK Material as a breaking release. Consumers on Flutter
+older than 3.44 cannot resolve `material_ui` 1.x.
diff --git a/docs/lessons/widget-tests.md b/docs/lessons/widget-tests.md
new file mode 100644
index 0000000..e3618e7
--- /dev/null
+++ b/docs/lessons/widget-tests.md
@@ -0,0 +1,16 @@
+# Lessons
+
+## Widget tests need Directionality for Text
+
+Pumping `Text` (or any `RichText`) under `Shimmer` alone fails with
+`No Directionality widget found`. Wrap the widget under test in
+`Directionality` (or `MaterialApp`) inside the test helper, even when
+the production widget does not require it.
+
+## Do not assert animation completion at exactly `period`
+
+`AnimationController` may still be running after `pump(period)` because
+the ticker starts on a later frame. For finite `loop` tests, use
+`pumpAndSettle()`. To prove `period` updates without restarting, lengthen
+the duration near the end of a cycle and assert it finishes in the
+remaining fraction of the **new** duration.
diff --git a/docs/optimization.md b/docs/optimization.md
new file mode 100644
index 0000000..4768f96
--- /dev/null
+++ b/docs/optimization.md
@@ -0,0 +1,68 @@
+# Optimization notes
+
+Changes already applied in this pass are listed first. Items below that are
+suggestions: they improve the package but change behavior, pub constraints, or
+the public API enough to confirm before shipping.
+
+## Applied
+
+- Start infinite loops with `AnimationController.repeat()` instead of one
+ `forward()` cycle then `repeat()`.
+- Update `controller.duration` when `period` changes.
+- Restart cleanly when `enabled` goes from `false` to `true`, including after
+ a finite `loop` has finished.
+- `direction` calls `markNeedsPaint()` rather than `markNeedsLayout()`.
+- Extract `shimmerHighlightRect` so highlight travel can be unit-tested.
+- Remove leftover `lib/main.dart` (default Flutter counter, not part of the
+ package API).
+
+## Rendering
+
+1. **One shimmer per screen, not per row.** The example already does this.
+ Document it in app code reviews: each `Shimmer` owns a ticker and a
+ compositing layer.
+2. **Optional `RepaintBoundary` around `Shimmer`.** Isolates the shader
+ animation from ancestors. Apps can wrap it; adding it inside the package
+ can surprise layout that relies on parent layer merging.
+3. **Reuse the `Shader` when only `percent` is unchanged.** Today
+ `createShader` runs every paint, which is required because the rect moves.
+ If `percent` and `size` are unchanged, skip shader creation (the existing
+ setters already avoid extra paints).
+4. **Avoid `saveLayer` elsewhere in the child.** `BlendMode.srcIn` already
+ composites; extra opacity/save layers on the child increase GPU cost.
+
+## Animation
+
+5. **`loop == 1` vs `loop == 0`.** Finite loops still use `forward` plus a
+ status listener. That is correct; do not switch finite loops to `repeat`
+ with a counter unless you also handle `enabled` mid-cycle.
+6. **Reset `_count` when `loop` shrinks** while the widget is still mounted
+ and enabled. Current code only restarts when the controller is idle.
+7. **Honor `Duration.zero` / negative `loop`.** Guard with asserts so bad
+ values fail in debug instead of hanging a ticker.
+
+## API (confirm before adding)
+
+8. **Theme-aware defaults.** A `Shimmer.theme(context)` helper that reads
+ `ColorScheme` would reduce boilerplate for skeleton screens, but it is a
+ new constructor.
+9. **`Semantics` / accessibility.** Announce “Loading” while `enabled` is
+ true. Must be opt-in so existing trees do not get duplicate semantics.
+10. **`fromColors` diagonal gradient.** `begin: topLeft` and
+ `end: centerRight` is slightly diagonal. A true horizontal band would
+ use `Alignment.centerLeft` → `Alignment.centerRight`. Changing it would
+ visually break apps that depend on the current slant.
+
+## Project hygiene
+
+11. **CI** still used `actions/checkout@v1`, Java 12, and the Flutter beta
+ channel. Unit tests for this package do not need Android toolchains.
+12. **`analysis_options.yaml`** is a dated Flutter-repo snapshot. Rules such
+ as `iterable_contains_unrelated_type` were removed from the linter.
+ Prefer `package:flutter_lints` once you are ready to fix new findings.
+13. **`example` pubspec** is still named `new_example` with a default
+ description. Rename for pub.dev example scoring if you republish.
+14. **LICENSE** text is the Dart project BSD header (Google Inc., 2013), not
+ a project-specific copyright. Confirm with the maintainer before editing.
+15. **Minimum Flutter SDK** was raised to `>=3.44.0` (Dart `^3.12.0`) with
+ the `material_ui` 4.0.0 migration.
diff --git a/docs/overview.md b/docs/overview.md
new file mode 100644
index 0000000..efaca82
--- /dev/null
+++ b/docs/overview.md
@@ -0,0 +1,53 @@
+# Project overview
+
+Context for working on [`shimmer`](https://pub.dev/packages/shimmer), a Flutter
+package that paints a moving highlight over placeholder UI.
+
+## Layout
+
+| Path | Role |
+|------|------|
+| `lib/shimmer.dart` | Entire public API (`Shimmer`, `ShimmerDirection`, `Shimmer.fromColors`) |
+| `test/shimmer_test.dart` | Widget tests plus geometry tests for `shimmerHighlightRect` |
+| `example/` | Sample app: loading list and slide-to-unlock |
+| `example/lib/placeholders.dart` | Skeleton blocks used by the loading-list demo |
+
+There is no plugin/platform code. Painting is done with a `ShaderMaskLayer`.
+
+Design widgets come from the standalone [`material_ui`](https://pub.dev/packages/material_ui) package (`package:material_ui/material_ui.dart`), not `package:flutter/material.dart`.
+
+## Widget tree
+
+```
+Shimmer (StatefulWidget + AnimationController)
+ └── AnimatedBuilder
+ └── _Shimmer (SingleChildRenderObjectWidget)
+ └── _ShimmerFilter (RenderProxyBox)
+ └── ShaderMaskLayer (BlendMode.srcIn)
+```
+
+`AnimatedBuilder` receives `child: widget.child` so the placeholder subtree
+is not rebuilt every tick. `_ShimmerFilter` only `markNeedsPaint()`s when
+`percent`, `gradient`, or `direction` change.
+
+## Public API
+
+- `Shimmer(...)` — caller supplies a `Gradient`.
+- `Shimmer.fromColors(...)` — builds a five-stop `LinearGradient` from
+ `baseColor` and `highlightColor`.
+- `ShimmerDirection` — `ltr`, `rtl`, `ttb`, `btt`.
+- `enabled` pauses the ticker; `loop: 0` repeats forever.
+
+Geometry for the sliding highlight is in `shimmerHighlightRect` (marked
+`@visibleForTesting`).
+
+## Conventions
+
+- Match the existing style in `lib/shimmer.dart` (explicit types, named
+ arguments, short dartdoc on the public widget).
+- Keep the public surface small. Do not add new widgets or dependencies
+ without an explicit request.
+- SDK constraint is Dart `^3.12.0` and Flutter `>=3.44.0` (required by
+ `material_ui` 1.x).
+- Import Material from `package:material_ui/material_ui.dart`. Do not add
+ `package:flutter/material.dart` back.
diff --git a/example/.gitignore b/example/.gitignore
index 24476c5..6c31954 100644
--- a/example/.gitignore
+++ b/example/.gitignore
@@ -5,9 +5,11 @@
*.swp
.DS_Store
.atom/
+.build/
.buildlog/
.history
.svn/
+.swiftpm/
migrate_working_dir/
# IntelliJ related
diff --git a/example/README.md b/example/README.md
index a82e13e..47508f9 100644
--- a/example/README.md
+++ b/example/README.md
@@ -1,16 +1,26 @@
-# new_example
+# Shimmer example
-A new Flutter project.
+Demo app for the [`shimmer`](https://pub.dev/packages/shimmer) package.
-## Getting Started
+## Screens
-This project is a starting point for a Flutter application.
+- **Loading List** — one `Shimmer.fromColors` wrapping a column of skeleton
+ placeholders (`BannerPlaceholder`, `TitlePlaceholder`, `ContentPlaceholder`).
+- **Slide To Unlock** — a highlight passing over a call-to-action row.
-A few resources to get you started if this is your first Flutter project:
+## Run
-- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
-- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
+From this directory:
-For help getting started with Flutter development, view the
-[online documentation](https://docs.flutter.dev/), which offers tutorials,
-samples, guidance on mobile development, and a full API reference.
+```bash
+flutter pub get
+flutter run
+```
+
+The demo imports Material from `package:material_ui/material_ui.dart` (Flutter
+3.44+). The app depends on the package via a path dependency in `pubspec.yaml`:
+
+```yaml
+shimmer:
+ path: ..
+```
diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml
index 61b6c4d..5bee4ff 100644
--- a/example/analysis_options.yaml
+++ b/example/analysis_options.yaml
@@ -7,6 +7,15 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
+analyzer:
+ exclude:
+ - build/**
+ - android/**
+ - ios/**
+ - web/**
+ - windows/**
+ - macos/**
+ - linux/**
include: package:flutter_lints/flutter.yaml
linter:
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/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj
index b53e5b7..2b24ec4 100644
--- a/example/ios/Runner.xcodeproj/project.pbxproj
+++ b/example/ios/Runner.xcodeproj/project.pbxproj
@@ -168,7 +168,7 @@
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
- LastUpgradeCheck = 1300;
+ LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
@@ -344,7 +344,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 = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -472,7 +472,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 = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -521,7 +521,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 = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
index e42adcb..e3773d4 100644
--- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
+++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -1,6 +1,6 @@
diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift
index 70693e4..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: [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 cf6e68e..db3cb66 100644
--- a/example/ios/Runner/Info.plist
+++ b/example/ios/Runner/Info.plist
@@ -2,6 +2,8 @@
+ CADisableMinimumFrameDurationOnPhone
+
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
@@ -24,6 +26,29 @@
$(FLUTTER_BUILD_NUMBER)
LSRequiresIPhoneOS
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ FlutterSceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
@@ -43,9 +68,5 @@
UIViewControllerBasedStatusBarAppearance
- CADisableMinimumFrameDurationOnPhone
-
- UIApplicationSupportsIndirectInputEvents
-
diff --git a/example/lib/main.dart b/example/lib/main.dart
index e6f8ded..bb28dbb 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -1,4 +1,4 @@
-import 'package:flutter/material.dart';
+import 'package:material_ui/material_ui.dart';
import 'package:shimmer/shimmer.dart';
import 'placeholders.dart';
diff --git a/example/lib/placeholders.dart b/example/lib/placeholders.dart
index 46c0e7f..2decdfe 100644
--- a/example/lib/placeholders.dart
+++ b/example/lib/placeholders.dart
@@ -1,4 +1,4 @@
-import 'package:flutter/material.dart';
+import 'package:material_ui/material_ui.dart';
class BannerPlaceholder extends StatelessWidget {
const BannerPlaceholder({Key? key}) : super(key: key);
diff --git a/example/pubspec.lock b/example/pubspec.lock
index 3fd0b9b..45a282b 100644
--- a/example/pubspec.lock
+++ b/example/pubspec.lock
@@ -21,26 +21,26 @@ packages:
dependency: transitive
description:
name: characters
- sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
+ sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
- version: "1.3.0"
+ version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
- sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
+ sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
- version: "1.1.1"
+ version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
- sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c"
+ sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
- version: "1.17.1"
+ version: "1.19.1"
cupertino_icons:
dependency: "direct main"
description:
@@ -49,14 +49,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
+ cupertino_ui:
+ dependency: transitive
+ description:
+ name: cupertino_ui
+ sha256: "7ed8ce4159d342eec4c65f4ea6eec57adaf9365404378541f38efc1da20a5b3d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.0"
fake_async:
dependency: transitive
description:
name: fake_async
- sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
+ sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
- version: "1.3.1"
+ version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
@@ -70,19 +78,48 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.1"
+ flutter_localizations:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
- js:
+ intl:
+ dependency: transitive
+ description:
+ name: intl
+ sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.20.3"
+ leak_tracker:
+ dependency: transitive
+ description:
+ name: leak_tracker
+ sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
+ url: "https://pub.dev"
+ source: hosted
+ version: "11.0.2"
+ leak_tracker_flutter_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_flutter_testing
+ sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.10"
+ leak_tracker_testing:
dependency: transitive
description:
- name: js
- sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
+ name: leak_tracker_testing
+ sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
- version: "0.6.7"
+ version: "3.0.2"
lints:
dependency: transitive
description:
@@ -95,46 +132,54 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb"
+ sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
- version: "0.12.15"
+ version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
- sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724
+ sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
- version: "0.2.0"
+ version: "0.13.0"
+ material_ui:
+ dependency: "direct main"
+ description:
+ name: material_ui
+ sha256: "4f3f38b9953df0a87d6bf5f21880029f77c47048487d5339410c39936be4683b"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.1"
meta:
dependency: transitive
description:
name: meta
- sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
+ sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
- version: "1.9.1"
+ version: "1.19.0"
path:
dependency: transitive
description:
name: path
- sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917"
+ sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
- version: "1.8.3"
+ version: "1.9.1"
shimmer:
dependency: "direct main"
description:
path: ".."
relative: true
source: path
- version: "3.0.0"
+ version: "4.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
- version: "0.0.99"
+ version: "0.0.0"
source_span:
dependency: transitive
description:
@@ -147,18 +192,18 @@ packages:
dependency: transitive
description:
name: stack_trace
- sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5
+ sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
- version: "1.11.0"
+ version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
- sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8"
+ sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
- version: "2.1.1"
+ version: "2.1.4"
string_scanner:
dependency: transitive
description:
@@ -179,18 +224,26 @@ packages:
dependency: transitive
description:
name: test_api
- sha256: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb
+ sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
- version: "0.5.1"
+ version: "0.7.12"
vector_math:
dependency: transitive
description:
name: vector_math
- sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
+ sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev"
source: hosted
- version: "2.1.4"
+ version: "2.4.2"
+ vm_service:
+ dependency: transitive
+ description:
+ name: vm_service
+ sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
+ url: "https://pub.dev"
+ source: hosted
+ version: "15.3.0"
sdks:
- dart: ">=3.0.1 <4.0.0"
- flutter: ">=1.9.1"
+ dart: ">=3.12.0 <4.0.0"
+ flutter: ">=3.44.0"
diff --git a/example/pubspec.yaml b/example/pubspec.yaml
index 31bae2e..ce47ff9 100644
--- a/example/pubspec.yaml
+++ b/example/pubspec.yaml
@@ -5,17 +5,15 @@ publish_to: 'none'
version: 1.0.0+1
environment:
- sdk: '>=3.0.1 <4.0.0'
+ sdk: ^3.12.0
dependencies:
+ cupertino_icons: ^1.0.2
flutter:
sdk: flutter
-
- cupertino_icons: ^1.0.2
-
+ material_ui: ^1.0.1
shimmer:
path: ..
-
dev_dependencies:
flutter_test:
sdk: flutter
diff --git a/lib/main.dart b/lib/main.dart
deleted file mode 100644
index dda5554..0000000
--- a/lib/main.dart
+++ /dev/null
@@ -1,125 +0,0 @@
-import 'package:flutter/material.dart';
-
-void main() {
- runApp(const MyApp());
-}
-
-class MyApp extends StatelessWidget {
- const MyApp({super.key});
-
- // This widget is the root of your application.
- @override
- Widget build(BuildContext context) {
- return MaterialApp(
- title: 'Flutter Demo',
- theme: ThemeData(
- // This is the theme of your application.
- //
- // TRY THIS: Try running your application with "flutter run". You'll see
- // the application has a blue toolbar. Then, without quitting the app,
- // try changing the seedColor in the colorScheme below to Colors.green
- // and then invoke "hot reload" (save your changes or press the "hot
- // reload" button in a Flutter-supported IDE, or press "r" if you used
- // the command line to start the app).
- //
- // Notice that the counter didn't reset back to zero; the application
- // state is not lost during the reload. To reset the state, use hot
- // restart instead.
- //
- // This works for code too, not just values: Most code changes can be
- // tested with just a hot reload.
- colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
- useMaterial3: true,
- ),
- home: const MyHomePage(title: 'Flutter Demo Home Page'),
- );
- }
-}
-
-class MyHomePage extends StatefulWidget {
- const MyHomePage({super.key, required this.title});
-
- // This widget is the home page of your application. It is stateful, meaning
- // that it has a State object (defined below) that contains fields that affect
- // how it looks.
-
- // This class is the configuration for the state. It holds the values (in this
- // case the title) provided by the parent (in this case the App widget) and
- // used by the build method of the State. Fields in a Widget subclass are
- // always marked "final".
-
- final String title;
-
- @override
- State createState() => _MyHomePageState();
-}
-
-class _MyHomePageState extends State {
- int _counter = 0;
-
- void _incrementCounter() {
- setState(() {
- // This call to setState tells the Flutter framework that something has
- // changed in this State, which causes it to rerun the build method below
- // so that the display can reflect the updated values. If we changed
- // _counter without calling setState(), then the build method would not be
- // called again, and so nothing would appear to happen.
- _counter++;
- });
- }
-
- @override
- Widget build(BuildContext context) {
- // This method is rerun every time setState is called, for instance as done
- // by the _incrementCounter method above.
- //
- // The Flutter framework has been optimized to make rerunning build methods
- // fast, so that you can just rebuild anything that needs updating rather
- // than having to individually change instances of widgets.
- return Scaffold(
- appBar: AppBar(
- // TRY THIS: Try changing the color here to a specific color (to
- // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
- // change color while the other colors stay the same.
- backgroundColor: Theme.of(context).colorScheme.inversePrimary,
- // Here we take the value from the MyHomePage object that was created by
- // the App.build method, and use it to set our appbar title.
- title: Text(widget.title),
- ),
- body: Center(
- // Center is a layout widget. It takes a single child and positions it
- // in the middle of the parent.
- child: Column(
- // Column is also a layout widget. It takes a list of children and
- // arranges them vertically. By default, it sizes itself to fit its
- // children horizontally, and tries to be as tall as its parent.
- //
- // Column has various properties to control how it sizes itself and
- // how it positions its children. Here we use mainAxisAlignment to
- // center the children vertically; the main axis here is the vertical
- // axis because Columns are vertical (the cross axis would be
- // horizontal).
- //
- // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
- // action in the IDE, or press "p" in the console), to see the
- // wireframe for each widget.
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- const Text(
- 'You have pushed the button this many times:',
- ),
- Text(
- '$_counter',
- style: Theme.of(context).textTheme.headlineMedium,
- ),
- ],
- ),
- ),
- floatingActionButton: FloatingActionButton(
- onPressed: _incrementCounter,
- tooltip: 'Increment',
- child: const Icon(Icons.add),
- ), // This trailing comma makes auto-formatting nicer for build methods.
- );
- }
-}
diff --git a/lib/shimmer.dart b/lib/shimmer.dart
index 159e63a..bc31d73 100644
--- a/lib/shimmer.dart
+++ b/lib/shimmer.dart
@@ -1,11 +1,11 @@
///
/// A package provides an easy way to add shimmer effect to Flutter application
///
-
library shimmer;
-import 'package:flutter/material.dart';
+import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
+import 'package:material_ui/material_ui.dart';
///
/// An enum defines all supported directions of shimmer effect
@@ -104,7 +104,7 @@ class Shimmer extends StatefulWidget {
]);
@override
- _ShimmerState createState() => _ShimmerState();
+ State createState() => _ShimmerState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
@@ -128,30 +128,54 @@ class _ShimmerState extends State with SingleTickerProviderStateMixin {
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: widget.period)
- ..addStatusListener((AnimationStatus status) {
- if (status != AnimationStatus.completed) {
- return;
- }
- _count++;
- if (widget.loop <= 0) {
- _controller.repeat();
- } else if (_count < widget.loop) {
- _controller.forward(from: 0.0);
- }
- });
+ ..addStatusListener(_onStatus);
if (widget.enabled) {
- _controller.forward();
+ _start();
+ }
+ }
+
+ void _onStatus(AnimationStatus status) {
+ if (status != AnimationStatus.completed) {
+ return;
+ }
+ _count++;
+ if (widget.loop <= 0) {
+ _controller.repeat();
+ } else if (_count < widget.loop) {
+ _controller.forward(from: 0.0);
+ }
+ }
+
+ void _start() {
+ _count = 0;
+ if (widget.loop <= 0) {
+ _controller.repeat();
+ } else {
+ _controller.forward(from: 0.0);
}
}
@override
void didUpdateWidget(Shimmer oldWidget) {
- if (widget.enabled) {
- _controller.forward();
- } else {
+ super.didUpdateWidget(oldWidget);
+ if (widget.period != oldWidget.period) {
+ _controller.duration = widget.period;
+ }
+ if (!widget.enabled) {
_controller.stop();
+ return;
+ }
+ if (!oldWidget.enabled) {
+ if (_controller.value == 0.0 || _controller.value == 1.0) {
+ _start();
+ } else {
+ _controller.forward();
+ }
+ return;
+ }
+ if (!_controller.isAnimating && widget.loop != oldWidget.loop) {
+ _start();
}
- super.didUpdateWidget(oldWidget);
}
@override
@@ -235,35 +259,18 @@ class _ShimmerFilter extends RenderProxyBox {
return;
}
_direction = newDirection;
- markNeedsLayout();
+ markNeedsPaint();
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null) {
assert(needsCompositing);
-
- final double width = child!.size.width;
- final double height = child!.size.height;
- Rect rect;
- double dx, dy;
- if (_direction == ShimmerDirection.rtl) {
- dx = _offset(width, -width, _percent);
- dy = 0.0;
- rect = Rect.fromLTWH(dx - width, dy, 3 * width, height);
- } else if (_direction == ShimmerDirection.ttb) {
- dx = 0.0;
- dy = _offset(-height, height, _percent);
- rect = Rect.fromLTWH(dx, dy - height, width, 3 * height);
- } else if (_direction == ShimmerDirection.btt) {
- dx = 0.0;
- dy = _offset(height, -height, _percent);
- rect = Rect.fromLTWH(dx, dy - height, width, 3 * height);
- } else {
- dx = _offset(-width, width, _percent);
- dy = 0.0;
- rect = Rect.fromLTWH(dx - width, dy, 3 * width, height);
- }
+ final Rect rect = shimmerHighlightRect(
+ size: child!.size,
+ direction: _direction,
+ percent: _percent,
+ );
layer ??= ShaderMaskLayer();
layer!
..shader = _gradient.createShader(rect)
@@ -274,8 +281,36 @@ class _ShimmerFilter extends RenderProxyBox {
layer = null;
}
}
+}
- double _offset(double start, double end, double percent) {
- return start + (end - start) * percent;
+/// Sliding rectangle used as the shader bounds for the highlight.
+///
+/// The band is three times the child's width or height so the highlight can
+/// travel fully across the child while [percent] goes from `0.0` to `1.0`.
+@visibleForTesting
+Rect shimmerHighlightRect({
+ required Size size,
+ required ShimmerDirection direction,
+ required double percent,
+}) {
+ final double width = size.width;
+ final double height = size.height;
+ switch (direction) {
+ case ShimmerDirection.rtl:
+ final double dx = _offset(width, -width, percent);
+ return Rect.fromLTWH(dx - width, 0.0, 3 * width, height);
+ case ShimmerDirection.ttb:
+ final double dy = _offset(-height, height, percent);
+ return Rect.fromLTWH(0.0, dy - height, width, 3 * height);
+ case ShimmerDirection.btt:
+ final double dy = _offset(height, -height, percent);
+ return Rect.fromLTWH(0.0, dy - height, width, 3 * height);
+ case ShimmerDirection.ltr:
+ final double dx = _offset(-width, width, percent);
+ return Rect.fromLTWH(dx - width, 0.0, 3 * width, height);
}
}
+
+double _offset(double start, double end, double percent) {
+ return start + (end - start) * percent;
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 6d43fe5..529cc8c 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,18 +1,24 @@
name: shimmer
-description: A package provides an easy way to add shimmer effect in Flutter project
-version: 3.0.0
+description: A lightweight Flutter widget that paints a moving highlight over skeleton placeholders and call-to-action text.
+version: 4.0.0
repository: https://github.com/hnvn/flutter_shimmer
issue_tracker: https://github.com/hnvn/flutter_shimmer/issues
contributors: Gregor Weber , Vasilliy Ditsyask
homepage: https://github.com/hnvn/flutter_shimmer
+topics:
+ - animation
+ - loading
+ - shimmer
+ - ui
environment:
- sdk: '>=2.17.0 <4.0.0'
- flutter: '>=1.9.1'
+ sdk: ^3.12.0
+ flutter: '>=3.44.0'
dependencies:
flutter:
sdk: flutter
+ material_ui: ^1.0.1
dev_dependencies:
flutter_test:
diff --git a/test/shimmer_test.dart b/test/shimmer_test.dart
index 3114794..181a6a7 100644
--- a/test/shimmer_test.dart
+++ b/test/shimmer_test.dart
@@ -1,16 +1,330 @@
-import 'package:flutter/material.dart';
+import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:material_ui/material_ui.dart';
import 'package:shimmer/shimmer.dart';
void main() {
- testWidgets('Shimmer.fromColors() can be constructed',
- (WidgetTester tester) async {
- await tester.pumpWidget(Shimmer.fromColors(
- child: Container(
+ const Color baseColor = Color(0xFFFF0000);
+ const Color highlightColor = Color(0xFFFFFF00);
+
+ Widget buildShimmer({
+ Key? key,
+ Widget? child,
+ Duration period = const Duration(milliseconds: 1500),
+ ShimmerDirection direction = ShimmerDirection.ltr,
+ int loop = 0,
+ bool enabled = true,
+ Gradient? gradient,
+ }) {
+ final Widget content = child ??
+ const SizedBox(
+ key: Key('shimmer-child'),
width: 100.0,
height: 100.0,
- ),
- baseColor: Colors.red,
- highlightColor: Colors.yellow));
+ );
+ final Widget shimmer = gradient != null
+ ? Shimmer(
+ key: key,
+ gradient: gradient,
+ period: period,
+ direction: direction,
+ loop: loop,
+ enabled: enabled,
+ child: content,
+ )
+ : Shimmer.fromColors(
+ key: key,
+ baseColor: baseColor,
+ highlightColor: highlightColor,
+ period: period,
+ direction: direction,
+ loop: loop,
+ enabled: enabled,
+ child: content,
+ );
+ return Directionality(
+ textDirection: TextDirection.ltr,
+ child: shimmer,
+ );
+ }
+
+ testWidgets('Shimmer.fromColors() can be constructed',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(buildShimmer());
+ expect(find.byType(Shimmer), findsOneWidget);
+ });
+
+ testWidgets('default constructor accepts a custom gradient',
+ (WidgetTester tester) async {
+ const LinearGradient gradient = LinearGradient(
+ colors: [Colors.black, Colors.white],
+ );
+
+ await tester.pumpWidget(buildShimmer(gradient: gradient));
+
+ final Shimmer shimmer = tester.widget(find.byType(Shimmer));
+ expect(shimmer.gradient, gradient);
+ });
+
+ testWidgets('fromColors builds a five-stop linear gradient',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(buildShimmer());
+
+ final Shimmer shimmer = tester.widget(find.byType(Shimmer));
+ expect(shimmer.gradient, isA());
+ final LinearGradient gradient = shimmer.gradient as LinearGradient;
+ expect(gradient.colors, const [
+ baseColor,
+ baseColor,
+ highlightColor,
+ baseColor,
+ baseColor,
+ ]);
+ expect(gradient.stops, const [0.0, 0.35, 0.5, 0.65, 1.0]);
+ });
+
+ testWidgets('renders the provided child', (WidgetTester tester) async {
+ await tester.pumpWidget(buildShimmer(
+ child: const Text('Loading'),
+ ));
+
+ expect(find.text('Loading'), findsOneWidget);
+ });
+
+ testWidgets('uses expected defaults', (WidgetTester tester) async {
+ await tester.pumpWidget(buildShimmer());
+
+ final Shimmer shimmer = tester.widget(find.byType(Shimmer));
+ expect(shimmer.direction, ShimmerDirection.ltr);
+ expect(shimmer.period, const Duration(milliseconds: 1500));
+ expect(shimmer.loop, 0);
+ expect(shimmer.enabled, isTrue);
+ });
+
+ testWidgets('animates when enabled', (WidgetTester tester) async {
+ await tester.pumpWidget(buildShimmer());
+ await tester.pump();
+
+ expect(tester.hasRunningAnimations, isTrue);
});
+
+ testWidgets('does not animate when disabled', (WidgetTester tester) async {
+ await tester.pumpWidget(buildShimmer(enabled: false));
+ await tester.pump();
+
+ expect(tester.hasRunningAnimations, isFalse);
+ });
+
+ testWidgets('pauses and resumes when enabled changes',
+ (WidgetTester tester) async {
+ const Key key = Key('shimmer');
+ await tester.pumpWidget(buildShimmer(key: key));
+ await tester.pump();
+ expect(tester.hasRunningAnimations, isTrue);
+
+ await tester.pumpWidget(buildShimmer(key: key, enabled: false));
+ await tester.pump();
+ expect(tester.hasRunningAnimations, isFalse);
+
+ await tester.pumpWidget(buildShimmer(key: key));
+ await tester.pump();
+ expect(tester.hasRunningAnimations, isTrue);
+ });
+
+ testWidgets('keeps running after the first cycle when loop is 0',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ buildShimmer(period: const Duration(milliseconds: 50)),
+ );
+ await tester.pump();
+ expect(tester.hasRunningAnimations, isTrue);
+
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.pump();
+ expect(tester.hasRunningAnimations, isTrue);
+ });
+
+ testWidgets('stops after the requested number of loops',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(buildShimmer(
+ period: const Duration(milliseconds: 50),
+ loop: 2,
+ ));
+ await tester.pump();
+ expect(tester.hasRunningAnimations, isTrue);
+
+ await tester.pumpAndSettle();
+ expect(tester.hasRunningAnimations, isFalse);
+ });
+
+ testWidgets('restarts a finished loop when enabled is toggled back on',
+ (WidgetTester tester) async {
+ const Key key = Key('shimmer');
+ await tester.pumpWidget(buildShimmer(
+ key: key,
+ loop: 1,
+ period: const Duration(milliseconds: 50),
+ ));
+ await tester.pumpAndSettle();
+ expect(tester.hasRunningAnimations, isFalse);
+
+ await tester.pumpWidget(buildShimmer(
+ key: key,
+ loop: 1,
+ period: const Duration(milliseconds: 50),
+ enabled: false,
+ ));
+ await tester.pumpWidget(buildShimmer(
+ key: key,
+ loop: 1,
+ period: const Duration(milliseconds: 50),
+ ));
+ await tester.pump();
+ expect(tester.hasRunningAnimations, isTrue);
+ });
+
+ testWidgets('applies a new period without restarting the animation',
+ (WidgetTester tester) async {
+ const Key key = Key('shimmer');
+ await tester.pumpWidget(buildShimmer(
+ key: key,
+ loop: 1,
+ period: const Duration(milliseconds: 500),
+ ));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 400));
+ expect(tester.hasRunningAnimations, isTrue);
+
+ await tester.pumpWidget(buildShimmer(
+ key: key,
+ loop: 1,
+ period: const Duration(milliseconds: 1000),
+ ));
+ await tester.pump(const Duration(milliseconds: 250));
+ expect(tester.hasRunningAnimations, isFalse);
+ });
+
+ testWidgets('does not rebuild the child on every animation tick',
+ (WidgetTester tester) async {
+ final _BuildCounter counter = _BuildCounter();
+
+ await tester.pumpWidget(buildShimmer(child: _BuildTracker(counter: counter)));
+ await tester.pump();
+ final int buildsAfterMount = counter.builds;
+
+ await tester.pump(const Duration(milliseconds: 100));
+ expect(counter.builds, buildsAfterMount);
+ });
+
+ testWidgets('debugFillProperties includes shimmer configuration',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(buildShimmer(
+ direction: ShimmerDirection.rtl,
+ loop: 3,
+ enabled: false,
+ ));
+
+ final Shimmer shimmer = tester.widget(find.byType(Shimmer));
+ final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
+ shimmer.debugFillProperties(builder);
+
+ final Map values = {
+ for (final DiagnosticsNode node in builder.properties)
+ if (node.name != null) node.name!: node.toDescription(),
+ };
+
+ expect(values['direction'], contains('rtl'));
+ expect(values['loop'], '3');
+ expect(values['enabled'], 'false');
+ expect(values['period'], isNotNull);
+ expect(values['gradient'], isNotNull);
+ });
+
+ group('shimmerHighlightRect', () {
+ const Size size = Size(100.0, 40.0);
+
+ test('ltr travels from left to right', () {
+ final Rect start = shimmerHighlightRect(
+ size: size,
+ direction: ShimmerDirection.ltr,
+ percent: 0.0,
+ );
+ final Rect end = shimmerHighlightRect(
+ size: size,
+ direction: ShimmerDirection.ltr,
+ percent: 1.0,
+ );
+
+ expect(start, const Rect.fromLTWH(-200.0, 0.0, 300.0, 40.0));
+ expect(end, const Rect.fromLTWH(0.0, 0.0, 300.0, 40.0));
+ expect(end.left, greaterThan(start.left));
+ });
+
+ test('rtl travels from right to left', () {
+ final Rect start = shimmerHighlightRect(
+ size: size,
+ direction: ShimmerDirection.rtl,
+ percent: 0.0,
+ );
+ final Rect end = shimmerHighlightRect(
+ size: size,
+ direction: ShimmerDirection.rtl,
+ percent: 1.0,
+ );
+
+ expect(start, const Rect.fromLTWH(0.0, 0.0, 300.0, 40.0));
+ expect(end, const Rect.fromLTWH(-200.0, 0.0, 300.0, 40.0));
+ expect(end.left, lessThan(start.left));
+ });
+
+ test('ttb travels from top to bottom', () {
+ final Rect start = shimmerHighlightRect(
+ size: size,
+ direction: ShimmerDirection.ttb,
+ percent: 0.0,
+ );
+ final Rect end = shimmerHighlightRect(
+ size: size,
+ direction: ShimmerDirection.ttb,
+ percent: 1.0,
+ );
+
+ expect(start, const Rect.fromLTWH(0.0, -80.0, 100.0, 120.0));
+ expect(end, const Rect.fromLTWH(0.0, 0.0, 100.0, 120.0));
+ expect(end.top, greaterThan(start.top));
+ });
+
+ test('btt travels from bottom to top', () {
+ final Rect start = shimmerHighlightRect(
+ size: size,
+ direction: ShimmerDirection.btt,
+ percent: 0.0,
+ );
+ final Rect end = shimmerHighlightRect(
+ size: size,
+ direction: ShimmerDirection.btt,
+ percent: 1.0,
+ );
+
+ expect(start, const Rect.fromLTWH(0.0, 0.0, 100.0, 120.0));
+ expect(end, const Rect.fromLTWH(0.0, -80.0, 100.0, 120.0));
+ expect(end.top, lessThan(start.top));
+ });
+ });
+}
+
+class _BuildCounter {
+ int builds = 0;
+}
+
+class _BuildTracker extends StatelessWidget {
+ const _BuildTracker({required this.counter});
+
+ final _BuildCounter counter;
+
+ @override
+ Widget build(BuildContext context) {
+ counter.builds++;
+ return const SizedBox(width: 100.0, height: 100.0);
+ }
}