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 -[![pub package](https://img.shields.io/pub/v/shimmer.svg)](https://pub.dartlang.org/packages/shimmer) ![](https://github.com/hnvn/flutter_shimmer/workflows/unit%20test/badge.svg) +[![pub package](https://img.shields.io/pub/v/shimmer.svg)](https://pub.dev/packages/shimmer) +![unit test](https://github.com/hnvn/flutter_shimmer/workflows/unit%20test/badge.svg) -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); + } }