Flutter最佳实践:路由弹窗终极版NSlidePopupRoute

一、需求来源

最近需要实现弹窗,动画方向分为:

  1. 从屏幕顶部滑动到屏幕内,top->Center。
  2. 从屏幕底部滑动到屏幕内,bottom->Center。
  3. 从屏幕左侧滑动到屏幕内,left->Center。
  4. 从屏幕右侧滑动到屏幕内,right->Center。
  5. 直接显示在屏幕中间,Center->Center。

最终实现以 Alignment 参数为动画方向,弹窗内容高度和宽度自定义,实现从哪个方向弹出就从哪个方向消失的高自由度。彻底突破了 ModalBottomSheet 的方向显示。 效果图如下:

二、使用示例

less 复制代码
import 'package:flutter/material.dart';
import 'package:n_slide_popup/n_slide_popup.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(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  List<Alignment> alignments = [    Alignment.topLeft,    Alignment.topCenter,    Alignment.topRight,    Alignment.centerLeft,    Alignment.center,    Alignment.centerRight,    Alignment.bottomLeft,    Alignment.bottomCenter,    Alignment.bottomRight,  ];

  var alignment = Alignment.center;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          const Text("direction from Alignment."),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              ...alignments.map((e) {
                var name = e.toString().split('.')[1];
                return ElevatedButton(
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.blue,
                    elevation: 0,
                    tapTargetSize: MaterialTapTargetSize.shrinkWrap,
                    minimumSize: const Size(64, 36),
                  ),
                  onPressed: () {
                    alignment = e;
                    debugPrint("$alignment ${alignment.x} ${alignment.y}");
                    onPopupRoute();
                  },
                  child: Text(
                    name,
                    style: TextStyle(color: Colors.white),
                  ),
                );
              }),
            ],
          )
        ],
      ),
    );
  }

  Future<void> onPopupRoute() async {
    final route = NSlidePopupRoute(
      from: alignment,
      builder: (_) {
        return buildPopupView(alignment: alignment, argsDismiss: {"b": "88"});
      },
    );
    final result = await Navigator.of(context).push(route);
    print(["result", result.runtimeType, result]);
  }

  Widget buildPopupView({required Alignment alignment, Map<String, dynamic>? argsDismiss}) {
    return Align(
      alignment: alignment,
      child: Container(
        width: 300,
        height: 400,
        alignment: Alignment.center,
        decoration: BoxDecoration(
          color: Colors.green,
          border: Border.all(color: Colors.blue),
          borderRadius: BorderRadius.all(Radius.circular(0)),
        ),
        child: ElevatedButton(
          onPressed: () {
            Navigator.of(context).pop(argsDismiss);
          },
          child: Text("dismiss"),
        ),
      ),
    );
  }
}

三、源码

php 复制代码
//
//  NSlidePopupRoute.dart
//  n_slide_popup
//
//  Created by shang on 2025/12/27.
//  Copyright © 2025/12/27 shang. All rights reserved.
//


import 'package:flutter/material.dart';

/// 最新滑入弹窗
class NSlidePopupRoute<T> extends PopupRoute<T> {
  NSlidePopupRoute({
    super.settings,
    required this.builder,
    this.from = Alignment.bottomCenter,
    this.barrierColor = const Color(0x80000000),
    this.barrierDismissible = true,
    this.duration = const Duration(milliseconds: 300),
    this.barrierLabel,
    this.curve = Curves.easeOutCubic,
  });

  final WidgetBuilder builder;

  /// 从哪个方向进入(推荐:topCenter / bottomCenter / centerLeft / centerRight)
  final Alignment from;

  final Duration duration;

  final Curve curve;

  @override
  final bool barrierDismissible;

  @override
  final Color barrierColor;

  @override
  final String? barrierLabel;

  @override
  Duration get transitionDuration => duration;

  // 展示
  static Future<T?> show<T>({
    required BuildContext context,
    required WidgetBuilder builder,
    Alignment from = Alignment.bottomCenter,
    Duration duration = const Duration(milliseconds: 300),
    Curve curve = Curves.easeOutCubic,
    Color barrierColor = const Color(0x80000000),
    bool barrierDismissible = true,
    String? barrierLabel,
    bool useRootNavigator = true,
    RouteSettings? routeSettings,
  }) {
    return Navigator.of(context, rootNavigator: useRootNavigator).push(
      NSlidePopupRoute<T>(
        builder: builder,
        from: from,
        duration: duration,
        curve: curve,
        barrierColor: barrierColor,
        barrierDismissible: barrierDismissible,
        barrierLabel: barrierLabel,
        settings: routeSettings,
      ),
    );
  }

  @override
  Widget buildPage(
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
  ) {
    // 直接返回背景和内容,不应用动画
    return Material(
      color: barrierColor,
      child: const SizedBox.expand(), // 只负责背景
    );
  }

  @override
  Widget buildTransitions(
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
    Widget child,
  ) {
    final content = builder(context);

    // ⭐ 中心弹窗:Fade
    if (from == Alignment.center) {
      return FadeTransition(
        opacity: animation.drive(
          CurveTween(curve: Curves.easeOut),
        ),
        child: content,
      );
    }

    // ⭐ 其余方向:Slide
    return FadeTransition(
      opacity: animation,
      child: SlideTransition(
        position: animation.drive(
          Tween<Offset>(
            begin: _alignmentToOffset(from),
            end: Offset.zero,
          ).chain(
            CurveTween(curve: curve),
          ),
        ),
        child: content,
      ),
    );
  }

  /// Alignment → Offset(关键点)
  Offset _alignmentToOffset(Alignment alignment) {
    return Offset(
      alignment.x.sign,
      alignment.y.sign,
    );
  }
}

最后、总结

  1. NSlidePopupRoute 代码实现极简,没有过多的底层封装,是为了追求极致的自由度。
  2. 当 alignment == Alignment.center 时,它是 Dialog弹窗。
  3. 从顶部滑出时,它可以是顶部通知 Toast。
  4. 从底部滑出时,它可以是 ModalBottomSheet,SnackBar。

github

pub.dev

相关推荐
懒大王、2 小时前
Vue3 + OpenSeadragon 实现 MRXS 病理切片图像预览
前端·javascript·vue.js·openseadragon·mrxs
子玖2 小时前
antd6的table排序功能
前端·react.js
程序员小李白2 小时前
动画2详细解析
前端·css·css3
eason_fan2 小时前
Rspack核心解析:Rust重写Webpack的性能革命与本质
前端·前端工程化
诗意地回家2 小时前
淘宝小游戏反编译
开发语言·前端·javascript
徐同保2 小时前
react两个组件中间加一个可以拖动跳转左右大小的功能
前端·javascript·react.js
爱迪斯通3 小时前
MANUS:用于视觉、语言、行动模型创建的高保真第一人称数据采集设备
前端
bjzhang753 小时前
使用 HTML + JavaScript 实现在线知识挑战
前端·javascript·html
薛定谔的猫23 小时前
Cursor 系列(3):关于MCP
前端·cursor·mcp