Flutter动画过渡插件heroine的使用

发布于 1周前 作者 gougou168 来自 Flutter

Flutter动画过渡插件Heroine的使用

Heroine

Code Coverage Powered by Mason lints by lintervention Bluesky

Flutter最迷人的英雄(Hero)过渡动画库。提供流畅、可自定义弹跳和持续时间的弹簧基础英雄过渡,支持拖动关闭手势,带有速度感知动画,以及适应导航手势的路由感知转换。

“这将成为如此吸引人的 #FlutterDev 交互体验!”
~ Mike Rydstrom

特性 🎯

  • 🌊 流畅的基于弹簧的英雄过渡,具有可定制的弹跳和持续时间
  • ♻️ 支持拖动关闭手势,带有速度感知动画
  • 🎨 美丽的过渡效果,具有可定制的shuttle builder
  • 📱 适应导航手势的路由感知转换
  • 🎯 与Flutter导航系统的无缝集成

尝试一下

在线示例

安装 💻

确保你已经安装了Flutter SDK

在你的pubspec.yaml中添加依赖:

dependencies:
  heroine: ^latest_version

或者通过命令行安装:

flutter pub add heroine

使用方法 💡

设置HeroineController

为了在应用中使用Heroine,你需要在应用的导航器中注册一个HeroineController

MaterialApp(
  home: MyHomePage(),
  navigatorObservers: [HeroineController()],
)

注意:在某些嵌套路由场景中,可能需要在所有嵌套的导航器中也注册HeroineController

基本Hero Transition

使用Heroine进行基于弹簧的英雄过渡,类似于Hero widget的用法。

// 在源页面
Heroine(
  tag: 'unique-tag',
  child: MyWidget(),
)

// 在目标页面
Heroine(
  tag: 'unique-tag',
  child: MyExpandedWidget(),
)

自定义过渡效果

选择预定义的shuttle builder或创建自己的shuttle builder。

Heroine(
  tag: 'unique-tag',
  flightShuttleBuilder: const FlipShuttleBuilder(
    axis: Axis.vertical,
    halfFlips: 1,
  ),
  spring: SimpleSpring.bouncy, // 自定义弹簧效果
  child: MyWidget(),
)

可用的shuttle builder包括:

  • FadeShuttleBuilder - 平滑淡入淡出过渡
  • FlipShuttleBuilder - 3D翻转动画
  • SingleShuttleBuilder - 只显示目标widget,类似Flutter默认的英雄过渡

拖动关闭

通过将Heroine包装在DragDismissable中,启用带有弹簧返回动画的拖动关闭手势。

DragDismissable(
  onDismiss: () => Navigator.pop(context),
  child: Heroine(
    tag: 'unique-tag',
    child: MyWidget(),
  ),
)

Heroine-aware routes

使你的路由响应Heroine的关闭手势。

class MyCustomRoute<T> extends PageRoute<T> with HeroinePageRouteMixin {
  // ... 你的路由实现,详见示例代码
}

// 在UI中响应关闭手势
ReactToHeroineDismiss(
  builder: (context, progress, offset, child) {
    return Opacity(
      opacity: 1 - progress,
      child: child,
    );
  },
  child: MyWidget(),
)

弹簧配置

Heroine使用Springster进行弹簧动画。你可以自定义弹簧行为。

const mySpring = SimpleSpring(
  durationSeconds: 0.5, // 结束时间
  bounce: 0.2,   // 弹跳量 (-1 到 1)
);

// 或者使用阻尼比例
const mySpring = SimpleSpring.withDamping(
  dampingFraction: 0.7,
  durationSeconds: 0.5,
);

高级功能 🚀

速度感知过渡

为更平滑的过渡提供速度信息。

HeroineVelocity(
  velocity: dragVelocity,
  child: Heroine(
    tag: 'unique-tag',
    child: MyWidget(),
  ),
)

路由过渡持续时间

可以让Heroine调整其弹簧的时间以匹配路由过渡的持续时间。

Heroine(
  tag: 'unique-tag',
  adjustToRouteTransitionDuration: true,
  child: MyWidget(),
)

最佳实践 📝

  1. 为每个英雄对使用唯一标签
  2. 保持两个路由中英雄widget的形状相似
  3. 考虑使用adjustToRouteTransitionDuration以获得更平滑的过渡
  4. 测试不同弹簧配置下的过渡效果
  5. 使用自定义shuttle builder处理边缘情况

示例代码

以下是完整的示例代码,展示了如何在项目中使用Heroine。

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:heroine/heroine.dart';
import 'package:springster/springster.dart';

final springNotifier = ValueNotifier(SimpleSpring.bouncy);
final flightShuttleNotifier = ValueNotifier<HeroineShuttleBuilder>(const FlipShuttleBuilder());
final adjustSpringTimingToRoute = ValueNotifier(false);
final detailsPageAspectRatio = ValueNotifier(1.0);

void main() async {
  runApp(HeroineExampleApp());
}

class HeroineExampleApp extends StatelessWidget {
  const HeroineExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: Listenable.merge(
        [
          springNotifier,
          flightShuttleNotifier,
          adjustSpringTimingToRoute,
          detailsPageAspectRatio,
        ],
      ),
      builder: (context, child) => CupertinoApp(
        debugShowCheckedModeBanner: false,
        onGenerateRoute: (settings) => MyCustomRoute(
          builder: (context) => HeroineExample(),
          title: 'Heroine Example',
        ),
        navigatorObservers: [HeroineController()],
      ),
    );
  }
}

class HeroineExample extends StatelessWidget {
  const HeroineExample({super.key});

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<double>(
      valueListenable: ModalRoute.of(context)?.secondaryAnimation ?? AlwaysStoppedAnimation(0),
      builder: (context, value, child) {
        final easedValue = Easing.standard.flipped.transform(value);

        return ColorFiltered(
          colorFilter: ColorFilter.mode(
            CupertinoTheme.of(context).barBackgroundColor.withAlpha((.5 * easedValue).round()),
            BlendMode.srcOver,
          ),
          child: child!,
        );
      },
      child: CupertinoPageScaffold(
        child: CustomScrollView(
          slivers: [
            CupertinoSliverNavigationBar(
              trailing: MainSettingsButton(),
            ),
            SliverPadding(
              padding: const EdgeInsets.all(32),
              sliver: SliverGrid(
                gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
                  maxCrossAxisExtent: 250,
                  mainAxisSpacing: 32,
                  crossAxisSpacing: 32,
                ),
                delegate: SliverChildBuilderDelegate(
                  (context, index) => Heroine(
                    tag: index,
                    spring: springNotifier.value,
                    adjustToRouteTransitionDuration: adjustSpringTimingToRoute.value,
                    child: Cover(
                      index: index,
                      onPressed: () {
                        Navigator.push(
                          context,
                          MyCustomRoute(
                            fullscreenDialog: true,
                            title: 'Details',
                            builder: (context) => DetailsPage(index: index),
                          ),
                        );
                      },
                    ),
                  ),
                  childCount: 100,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class Cover extends StatelessWidget {
  const Cover({
    super.key,
    required this.index,
    this.onPressed,
    this.isFlipped = false,
  });

  final int index;
  final bool isFlipped;
  final VoidCallback? onPressed;

  @override
  Widget build(BuildContext context) {
    final shape = SmoothRectangleBorder(
      borderRadius: SmoothBorderRadius(
        cornerRadius: 32,
        cornerSmoothing: .6,
      ),
    );
    return FilledButton(
      style: FilledButton.styleFrom(
        splashFactory: NoSplash.splashFactory,
        padding: EdgeInsets.all(32),
        shape: shape,
        backgroundColor: !isFlipped ? Colors.transparent : CupertinoColors.systemGrey4,
        foregroundColor: !isFlipped ? CupertinoColors.white : CupertinoColors.black,
        shadowColor: Colors.brown.withOpacity(.3),
        elevation: isFlipped ? 24 : 8,
        backgroundBuilder: (context, states, child) => DecoratedBox(
          decoration: ShapeDecoration(
            shape: shape,
            gradient: LinearGradient(
              colors: [
                CupertinoColors.systemGrey5.withOpacity(.88),
                CupertinoColors.systemGrey3.withOpacity(.75),
              ],
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
            ),
            image: isFlipped
                ? null
                : DecorationImage(
                    image: NetworkImage('https://picsum.photos/800/800?random=$index'),
                    fit: BoxFit.cover,
                  ),
          ),
          child: child,
        ),
      ),
      child: isFlipped
          ? Align(
              alignment: Alignment.bottomLeft,
              child: Text(
                'Image #$index',
                style: CupertinoTheme.of(context).textTheme.navLargeTitleTextStyle.copyWith(
                      color: CupertinoColors.inactiveGray,
                    ),
              ),
            )
          : const SizedBox.shrink(),
      onPressed: onPressed,
    );
  }
}

class DetailsPage extends StatelessWidget {
  const DetailsPage({super.key, required this.index});

  final int index;

  @override
  Widget build(BuildContext context) {
    return ReactToHeroineDismiss(
      builder: (context, progress, offset, child) {
        final opacity = 1 - progress;

        return ClipRect(
          child: BackdropFilter(
            filter: ImageFilter.blur(sigmaX: opacity * 20, sigmaY: opacity * 20),
            child: CupertinoPageScaffold(
              backgroundColor: CupertinoTheme.of(context).scaffoldBackgroundColor.withOpacity(opacity),
              child: child!,
            ),
          ),
        );
      },
      child: CustomScrollView(
        slivers: [
          ReactToHeroineDismiss(
            builder: (context, progress, offset, child) {
              final opacity = 1 - progress;
              return SliverOpacity(
                opacity: opacity,
                sliver: child!,
              );
            },
            child: CupertinoSliverNavigationBar(
              largeTitle: SizedBox(),
              trailing: DetailsPageSettingsButton(),
            ),
          ),
          SliverToBoxAdapter(
            child: SizedBox(height: 16),
          ),
          SliverToBoxAdapter(
            child: Container(
              height: MediaQuery.sizeOf(context).height * .5,
              padding: const EdgeInsets.symmetric(horizontal: 32),
              child: Center(
                child: SpringBuilder(
                  value: detailsPageAspectRatio.value,
                  spring: SimpleSpring.bouncy,
                  builder: (context, value, child) => AspectRatio(
                    aspectRatio: value,
                    child: DragDismissable(
                      onDismiss: () => Navigator.pop(context),
                      child: child!,
                    ),
                  ),
                  child: Heroine(
                    tag: index,
                    adjustToRouteTransitionDuration: adjustSpringTimingToRoute.value,
                    spring: springNotifier.value,
                    flightShuttleBuilder: flightShuttleNotifier.value,
                    child: Cover(
                      index: index,
                      isFlipped: true,
                      onPressed: () => Navigator.pop(context),
                    ),
                  ),
                ),
              ),
            ),
          ),
          SliverToBoxAdapter(
            child: SizedBox(height: 48),
          ),
          ReactToHeroineDismiss(
            builder: (context, progress, offset, child) {
              final opacity = 1 - progress;
              return SliverOpacity(
                opacity: opacity,
                sliver: child!,
              );
            },
            child: SliverToBoxAdapter(
              child: Center(
                child: Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 32),
                  child: ConstrainedBox(
                    constraints: BoxConstraints(maxWidth: 600),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          'Details',
                          style: CupertinoTheme.of(context).textTheme.textStyle.copyWith(
                                fontSize: 24,
                                fontWeight: FontWeight.bold,
                                color: CupertinoTheme.of(context).textTheme.textStyle.color?.withOpacity(.8),
                              ),
                        ),
                        SizedBox(height: 16),
                        Text(
                          Lorem.paragraph(numParagraphs: 10),
                          style: CupertinoTheme.of(context).textTheme.textStyle,
                        ),
                      ],
                    ),
                  ),
                ),
              ),
            ),
          )
        ],
      ),
    );
  }
}

class MyCustomRoute<T> extends PageRoute<T> with CupertinoRouteTransitionMixin, HeroinePageRouteMixin {
  MyCustomRoute({
    required this.title,
    required this.builder,
    this.fullscreenDialog = false,
  });

  final String title;

  final Widget Function(BuildContext context) builder;

  final bool fullscreenDialog;

  @override
  bool get maintainState => false;

  @override
  bool get opaque => false;

  @override
  Widget buildContent(BuildContext context) => builder(context);

  @override
  bool canTransitionTo(TransitionRoute nextRoute) {
    return super.canTransitionTo(nextRoute) || (nextRoute is MyCustomRoute && nextRoute.fullscreenDialog);
  }

  @override
  Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
    return CupertinoRouteTransitionMixin.buildPageTransitions(this, context, animation, AlwaysStoppedAnimation(0), child);
  }
}

这个示例展示了如何使用Heroine来创建平滑且富有吸引力的过渡动画,同时结合了其他一些Flutter特性,如CupertinoAppCustomScrollView等。希望这个示例能帮助你在自己的项目中更好地理解和使用Heroine。


更多关于Flutter动画过渡插件heroine的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html

1 回复

更多关于Flutter动画过渡插件heroine的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html


当然,下面是一个关于如何在Flutter中使用Heroine插件来实现动画过渡的示例代码。Heroine是一个强大的Flutter插件,它扩展了Hero动画,提供了更加丰富的过渡效果。

首先,你需要在你的pubspec.yaml文件中添加Heroine的依赖:

dependencies:
  flutter:
    sdk: flutter
  heroine: ^x.y.z  # 请替换为最新版本号

然后运行flutter pub get来获取依赖。

以下是一个简单的示例,展示了如何在两个页面之间使用Heroine进行动画过渡。

主文件 main.dart

import 'package:flutter/material.dart';
import 'package:heroine/heroine.dart';
import 'page_one.dart';
import 'page_two.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Heroine Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      initialRoute: '/',
      routes: {
        '/': (context) => PageOne(),
        '/page_two': (context) => PageTwo(),
      },
    );
  }
}

页面一 page_one.dart

import 'package:flutter/material.dart';
import 'package:heroine/heroine.dart';
import 'page_two.dart';

class PageOne extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Page One'),
      ),
      body: Center(
        child: HeroineWidget(
          tag: 'hero_image',
          child: GestureDetector(
            onTap: () {
              Navigator.pushNamed(context, '/page_two');
            },
            child: Image.network(
              'https://via.placeholder.com/150',
              width: 150,
              height: 150,
            ),
          ),
        ),
      ),
    );
  }
}

页面二 page_two.dart

import 'package:flutter/material.dart';
import 'package:heroine/heroine.dart';

class PageTwo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Page Two'),
      ),
      body: Center(
        child: HeroineWidget(
          tag: 'hero_image',
          child: Image.network(
            'https://via.placeholder.com/150',
            width: 150,
            height: 150,
          ),
        ),
      ),
    );
  }
}

解释

  1. 添加依赖:在pubspec.yaml文件中添加Heroine的依赖。
  2. 主文件:创建一个MyApp类,它使用MaterialApp来设置应用的路由。
  3. 页面一PageOne包含一个HeroineWidget,这个Widget包裹了一个图片,并设置了一个点击事件来导航到PageTwo
  4. 页面二PageTwo也包含一个HeroineWidget,它使用相同的tag来标识过渡动画的目标Widget。

当从PageOne点击图片导航到PageTwo时,Heroine会识别具有相同tag的Widget,并应用动画过渡效果。

请确保你已经替换了heroine: ^x.y.z为实际的最新版本号,并根据需要调整代码中的URL和其他参数。

希望这能帮助你理解如何在Flutter中使用Heroine插件来实现动画过渡!

回到顶部