flutter_miuix 1.1.0 copy "flutter_miuix: ^1.1.0" to clipboard
flutter_miuix: ^1.1.0 copied to clipboard

A HyperOS-style component library for Flutter, ported from miuix. 45+ widgets with squircle corners, Folme spring motion, liquid glass blur, and Monet dynamic colors.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter_miuix/miuix.dart';

import 'showcase/buttons.dart';
import 'showcase/common.dart';
import 'showcase/display.dart';
import 'showcase/feedback.dart';
import 'showcase/inputs.dart';
import 'showcase/menus.dart';
import 'showcase/pickers.dart';
import 'showcase/overlays.dart';
import 'showcase/utility.dart';
import 'showcase/theming.dart';
import 'showcase/foundation.dart';
import 'showcase/blur.dart';
import 'showcase/navigation.dart';
import 'showcase/preferences.dart';

void main() {
  runApp(const MiuixShowcaseApp());
}

/// miuix Flutter 组件库 Demo 入口。
///
/// 用 [MiuixSystemTheme] 跟随系统明暗模式;用 [MiuixScaffold] + [MiuixTopAppBar]
/// 搭建主页,列表展示各组件分类,点击进入对应演示页。
class MiuixShowcaseApp extends StatelessWidget {
  const MiuixShowcaseApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MiuixSystemTheme(
      child: Builder(
        builder: (context) {
          final themeData = MiuixTheme.of(context);
          return MaterialApp(
            title: 'flutter_miuix',
            debugShowCheckedModeBanner: false,
            theme: ThemeData(
              useMaterial3: true,
              colorScheme: ColorScheme.fromSeed(
                seedColor: themeData.colors.primary,
                brightness: themeData.brightness,
              ),
              brightness: themeData.brightness,
              fontFamily: 'MiSans',
            ),
            home: const _HomePage(),
          );
        },
      ),
    );
  }
}

/// 移动端 / 桌面端的分界宽度(逻辑像素)。
const double _kDesktopBreakpoint = 768;

class _HomePage extends StatefulWidget {
  const _HomePage();

  @override
  State<_HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<_HomePage>
    with SingleTickerProviderStateMixin {
  final _scrollBehavior = MiuixExitUntilCollapsedScrollBehavior();
  // 横屏(宽屏)WinUI3 布局:左侧可折叠导航栏 + 右侧内容区。
  final _railState =
      MiuixNavigationRailState(initialValue: MiuixNavigationRailValue.expanded);
  int _selectedRailIndex = 0;
  // 桌面端内联搜索框的焦点节点。移动端折叠态的搜索框**不参与焦点**
  // (见 build 里的 IgnorePointer),故此节点仅桌面端使用。
  final _searchFocusNode = FocusNode();
  // 移动端全屏浮层里输入框的独立焦点节点——移动端唯一真正持有焦点的输入框。
  final _overlaySearchFocusNode = FocusNode();
  // 首页搜索栏容器的 key,用于测量其屏幕位置作为浮层展开动画的起点。
  final _searchBarKey = GlobalKey();

  // --- 搜索状态 ---
  String _query = '';
  bool _searchExpanded = false;
  late final AnimationController _searchAnim;

  // 搜索历史(内存态:最近在前、去重、上限 _kMaxHistory 条)。
  // 跨 app 重启的持久化可后续接入 shared_preferences。
  final List<String> _searchHistory = [];
  static const int _kMaxHistory = 10;

  // 移动端全屏搜索浮层
  OverlayEntry? _searchOverlay;

  // 所有组件入口(缓存,不在 build 中重复构建)
  late final List<_DemoEntry> _allEntries = [
    _DemoEntry(
      icon: 'create',
      title: '按钮 Button',
      summary:
          'MiuixButton / MiuixTextButton / MiuixIconButton / MiuixFloatingActionButton',
      page: const ButtonsShowcase(),
      keywords: ['按钮', '文字按钮', '图标按钮', '悬浮按钮', 'FAB', 'button', 'text', 'icon'],
    ),
    _DemoEntry(
      icon: 'edit',
      title: '输入 Input',
      summary:
          'MiuixTextField / MiuixSwitch / MiuixCheckbox / MiuixRadioButton / MiuixSlider',
      page: const InputsShowcase(),
      keywords: [
        '输入', '开关', '复选框', '单选框', '滑块', '文本框', '输入框',
        'switch', 'checkbox', 'radio', 'slider', 'textfield',
      ],
    ),
    _DemoEntry(
      icon: 'listView',
      title: '菜单选择 Menus',
      summary: 'MiuixDropdownPreference / SpinnerPreference / CascadingMenu',
      page: const MenusShowcase(),
      keywords: ['菜单', '下拉', '级联', 'dropdown', 'spinner', 'cascading'],
    ),
    _DemoEntry(
      icon: 'gridView',
      title: '显示 Display',
      summary:
          'MiuixText / MiuixCard / MiuixBadge / MiuixDivider / MiuixSmallTitle / MiuixBasicComponent',
      page: const DisplayShowcase(),
      keywords: ['显示', '卡片', '徽章', '分割线', '标题', 'card', 'badge', 'divider'],
    ),
    _DemoEntry(
      icon: 'listView',
      title: '列表项 Item',
      summary:
          'MiuixBasicComponent / Arrow / Switch / Checkbox / RadioButton / Slider Preference',
      page: const PreferencesShowcase(),
      keywords: ['列表', '列表项', '设置项', '偏好', 'preference', 'item', '箭头'],
    ),
    _DemoEntry(
      icon: 'edit',
      title: '选择器 Pickers',
      summary: 'MiuixNumberPicker / MiuixColorPicker / MiuixColorPalette / MiuixDatePicker',
      page: const PickersShowcase(),
      keywords: ['选择器', '数字选择', '颜色选择', '调色板', '日期选择', '日历', 'number', 'color', 'palette', 'date', 'calendar'],
    ),
    _DemoEntry(
      icon: 'info',
      title: '反馈 Feedback',
      summary:
          'MiuixSnackbar / MiuixTooltip / MiuixOverlayDialog / MiuixProgressIndicator',
      page: const FeedbackShowcase(),
      keywords: ['反馈', '提示', '弹窗', '对话框', '进度', 'snackbar', 'tooltip', 'dialog', 'progress'],
    ),
    _DemoEntry(
      icon: 'info',
      title: '浮层 Overlays',
      summary: 'MiuixOverlayBottomSheet / MiuixFloatingToolbar',
      page: const OverlaysShowcase(),
      keywords: ['浮层', '底部弹窗', '工具栏', 'bottomsheet', 'toolbar'],
    ),
    _DemoEntry(
      icon: 'sidebar',
      title: '导航 Navigation',
      summary:
          'MiuixTabRow / MiuixTabRowWithContour / MiuixNavigationBar / MiuixBreadcrumbBar',
      page: const NavigationShowcase(),
      keywords: ['导航', '标签', '底部导航', '面包屑', 'tab', 'breadcrumb'],
    ),
    _DemoEntry(
      icon: 'search',
      title: '实用 Utility',
      summary: 'MiuixSearchBar / ScrollBar / PullToRefresh / Surface',
      page: const UtilityShowcase(),
      keywords: ['实用', '搜索', '滚动', '下拉刷新', '表面', 'search', 'scroll', 'refresh', 'surface'],
    ),
    _DemoEntry(
      icon: 'create',
      title: '主题 Theming',
      summary: '动态取色 / 文本样式 / 配色角色 / 图标浏览',
      page: const ThemingShowcase(),
      keywords: ['主题', '动态取色', '文本样式', '配色', '图标', 'theme', 'color', 'style'],
    ),
    _DemoEntry(
      icon: 'image',
      title: '模糊 Blur',
      summary: 'MiuixTextureBlur / 液态玻璃(背景捕获 + 高斯模糊 + 颜色控制)',
      page: const BlurShowcase(),
      keywords: ['模糊', '液态玻璃', '高斯模糊', '毛玻璃', 'glass', 'gaussian', 'texture'],
    ),
    _DemoEntry(
      icon: 'gridView',
      title: '基础 Foundation',
      summary: 'MiuixSquircleBorder / MiuixScrollEndHaptic',
      page: const FoundationShowcase(),
      keywords: ['基础', '圆角', '触感', 'squircle', 'haptic', 'border'],
    ),
  ];

  // ---------- 生命周期 ----------

  @override
  void initState() {
    super.initState();
    _searchAnim = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
    _searchAnim.addStatusListener(_onSearchAnimStatus);
  }

  @override
  void dispose() {
    // 先直接摘掉浮层(不走 _removeOverlay,避免其 setState / 访问动画),
    // 再销毁动画与焦点节点,规避"动画 dispose 后又被访问"的断言。
    _searchOverlay?.remove();
    _searchOverlay = null;
    _searchAnim
      ..removeStatusListener(_onSearchAnimStatus)
      ..dispose();
    _searchFocusNode.dispose();
    _overlaySearchFocusNode.dispose();
    _railState.dispose();
    super.dispose();
  }

  // ---------- 搜索逻辑 ----------

  List<_DemoEntry> get _filteredEntries {
    if (_query.isEmpty) return _allEntries;
    final q = _query.toLowerCase();
    return _allEntries.where((e) {
      final haystack =
          '${e.title}\n${e.summary}\n${e.keywords.join(' ')}'.toLowerCase();
      return haystack.contains(q);
    }).toList();
  }

  /// 是否为桌面端布局(宽屏不需要全屏覆盖效果)。
  bool _isDesktop(BuildContext context) =>
      MediaQuery.sizeOf(context).width >= _kDesktopBreakpoint;

  // ---------- 搜索历史 ----------

  /// 记录一条搜索历史:去重后置顶,超出上限截断。空白串忽略。
  void _commitHistory(String query) {
    final q = query.trim();
    if (q.isEmpty) return;
    _searchHistory
      ..removeWhere((e) => e.toLowerCase() == q.toLowerCase())
      ..insert(0, q);
    if (_searchHistory.length > _kMaxHistory) {
      _searchHistory.removeRange(_kMaxHistory, _searchHistory.length);
    }
  }

  void _removeHistory(String query) {
    setState(() => _searchHistory.remove(query));
    _searchOverlay?.markNeedsBuild();
  }

  void _clearHistory() {
    setState(_searchHistory.clear);
    _searchOverlay?.markNeedsBuild();
  }

  /// 点击某条历史:回填查询词并重搜。
  void _applyHistory(String query) {
    setState(() => _query = query);
    _searchOverlay?.markNeedsBuild();
  }

  // ---------- 移动端浮层管理 ----------

  /// 移动端点击折叠态搜索栏时调用:弹出全屏搜索浮层。
  ///
  /// 折叠态搜索框本身不持有焦点(被 [IgnorePointer] 包裹),点击由外层
  /// [GestureDetector] 捕获后直接走这里——不依赖焦点变化,因此不会与
  /// Flutter 焦点系统的异步时序产生竞态(这是"展开关闭后再点无反应"的根因)。
  void _openMobileSearch() {
    if (_searchOverlay != null) return;
    // 用搜索栏容器的 key 测量其屏幕位置作为动画起点。
    final box =
        _searchBarKey.currentContext?.findRenderObject() as RenderBox?;
    final overlay = Overlay.of(context);
    // 状态栏 / 刘海高度,用于展开后给系统栏预留位置。
    final topInset = MediaQuery.paddingOf(context).top;

    // 起始 y 坐标:搜索栏顶部在屏幕上的全局位置;若无法获取则从 200px 开始。
    final startY = box != null && box.hasSize
        ? box.localToGlobal(Offset.zero).dy
        : 200.0;

    _searchOverlay = OverlayEntry(
      builder: (ctx) => _MobileSearchPage(
        query: _query,
        onQueryChanged: (v) {
          setState(() => _query = v);
          _searchOverlay?.markNeedsBuild();
        },
        onSearch: (v) {
          setState(() => _commitHistory(v));
          _searchOverlay?.markNeedsBuild();
        },
        onDismiss: _dismissSearchOverlay,
        focusNode: _overlaySearchFocusNode,
        startY: startY,
        topInset: topInset,
        animation: _searchAnim,
        entries: _filteredEntries,
        history: _searchHistory,
        onHistoryTap: _applyHistory,
        onHistoryRemove: _removeHistory,
        onHistoryClear: _clearHistory,
        onCommitQuery: _commitHistory,
      ),
    );
    overlay.insert(_searchOverlay!);
    _searchAnim.forward(from: 0);
    // 触发首页重建以隐藏分组卡片。
    setState(() {});
  }

  void _removeOverlay() {
    if (_searchOverlay == null) return;
    _searchOverlay?.remove();
    _searchOverlay = null;
    _query = '';
    // 关键:把动画归零。点击结果跳转走的是"直接移除"路径(非取消按钮的
    // reverse),若不归零,_searchAnim 会卡在 1.0——首页分组卡片的 opacity
    // 绑定了它(1-fade=0 → 全透明空白),且 IgnorePointer(fade>0) 吞掉点击,
    // 于是"返回后卡片空白、搜索框点不开、再点还闪烁"。归零后卡片恢复可见可点。
    // 在 dispose 阶段 _searchAnim 已被 dispose,故加 mounted 守卫。
    if (mounted) {
      _searchAnim.value = 0;
      setState(() {});
    }
  }

  /// 立即移除浮层并收起键盘。
  /// 用于移动端点击遮罩或搜索结果后跳转前的清理。
  void _dismissSearchOverlay() {
    _removeOverlay();
    if (_overlaySearchFocusNode.hasFocus) _overlaySearchFocusNode.unfocus();
  }

  void _onSearchAnimStatus(AnimationStatus status) {
    if (status == AnimationStatus.dismissed) {
      _removeOverlay();
      // 动画结束后收起浮层输入框的键盘 / 焦点。
      if (_overlaySearchFocusNode.hasFocus) _overlaySearchFocusNode.unfocus();
    }
  }

  // ---------- UI 构建 ----------

  /// 首页分组卡片:所有组件分类入口。
  Widget _buildEntriesCard() {
    return GroupCard(
      children: [
        for (var i = 0; i < _allEntries.length; i++) ...[
          if (i > 0) const IndentDivider(),
          _EntryTile(entry: _allEntries[i]),
        ],
      ],
    );
  }

  /// 首页搜索栏。桌面端内联展开;移动端折叠态不参与焦点,点击开浮层。
  Widget _buildHomeSearchBar(BuildContext context, bool isDesktop) {
    final searchBar = MiuixSearchBar(
      expanded: isDesktop && _searchExpanded,
      onExpandedChange: (v) => setState(() {
        _searchExpanded = v;
        if (!v) _query = '';
      }),
      outsideEndAction: GestureDetector(
        onTap: () {
          setState(() {
            _searchExpanded = false;
            _query = '';
          });
          _searchFocusNode.unfocus();
        },
        child: Padding(
          padding: const EdgeInsets.only(right: 12),
          child: Text(
            '取消',
            style: TextStyle(
              fontSize: 15,
              color: MiuixTheme.of(context).colors.primary,
            ),
          ),
        ),
      ),
      inputField: MiuixInputField(
        query: _query,
        onQueryChange: (v) {
          setState(() => _query = v);
          _searchOverlay?.markNeedsBuild();
        },
        onSearch: (_) {},
        expanded: isDesktop && _searchExpanded,
        onExpandedChange: (v) {
          if (isDesktop) {
            setState(() {
              _searchExpanded = v;
              if (!v) _query = '';
            });
          }
        },
        // 移动端不传焦点节点:折叠态搜索框不应持有焦点。
        focusNode: isDesktop ? _searchFocusNode : null,
        label: '搜索组件…',
      ),
      content: isDesktop
          ? (_query.trim().isEmpty
                ? _SearchHistoryView(
                    history: _searchHistory,
                    onTap: _applyHistory,
                    onRemove: _removeHistory,
                    onClear: _clearHistory,
                  )
                : _DesktopSearchResults(
                    entries: _filteredEntries,
                    onCommitQuery: () => _commitHistory(_query),
                  ))
          : const SizedBox.shrink(),
    );

    if (isDesktop) return searchBar;

    // 移动端:屏蔽搜索框自身的指针(不抢焦点/不弹键盘),
    // 外层用透明 GestureDetector 捕获点击直接开浮层。
    return GestureDetector(
      behavior: HitTestBehavior.opaque,
      onTap: _openMobileSearch,
      child: IgnorePointer(child: searchBar),
    );
  }

  /// 宽屏 WinUI3 布局:
  /// - 顶部:全宽标题栏(app 名,跨整个窗口)。
  /// - 其下:左侧可折叠导航栏(☰ 折叠按钮 + Tab,与首页分组卡片同一批分类)
  ///         | 右侧内容区(直接渲染选中分类页面,无自己的标题栏)。
  Widget _buildWideLayout(BuildContext context) {
    final theme = MiuixTheme.of(context);
    return MiuixScaffold(
      // 全宽标题栏:app 名**左对齐**(放 navigationIcon 左侧槽,正好在侧栏 ☰ 折叠按钮上方),
      // 居中的 title 槽留空。
      topBar: MiuixSmallTopAppBar(
        title: '',
        navigationIcon: MiuixText(
          'flutter_miuix',
          style: theme.textStyles.title3,
          color: theme.colors.onSurface,
          fontWeight: FontWeight.w500,
          maxLines: 1,
          overflow: TextOverflow.ellipsis,
        ),
      ),
      content: (padding) => Material(
        // 透明 Material:给导航栏内部原生 Text 提供 Material 祖先(否则黄色下划线)。
        // 右侧内容页各自的 MiuixScaffold 正常嵌套其下。
        type: MaterialType.transparency,
        // padding.top = 标题栏高度:把侧栏 + 内容整体下推到标题栏之下。
        child: Padding(
          padding: EdgeInsets.only(top: padding.top),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              MiuixNavigationRail(
                state: _railState,
                // 顶部内边距已由标题栏承担,关掉自带窗口内边距避免重复留白。
                defaultWindowInsetsPadding: false,
                // 缩小侧栏项:图标 22、展开态上下内边距 10、展开字号 14(默认 28/14/16)。
                iconSize: 22,
                expandedItemVerticalPadding: 10,
                expandedLabelFontSize: 14,
                children: [
                  for (var i = 0; i < _allEntries.length; i++)
                    MiuixNavigationRailItem(
                      selected: i == _selectedRailIndex,
                      onPressed: () => setState(() => _selectedRailIndex = i),
                      icon: MiuixIcon(
                        vector:
                            MiuixIcons.extended.byName(_allEntries[i].icon)!,
                        size: 22,
                      ),
                      label: _allEntries[i].title,
                    ),
                ],
              ),
              // 右侧内容区:选中分类页面。ShowcaseEmbedded 让其不画自己的顶栏、
              // 内边距归零(顶部由上面全宽标题栏统一承担)。
              // KeyedSubtree(ValueKey) 确保切分类时正确重建。
              Expanded(
                child: ShowcaseEmbedded(
                  child: KeyedSubtree(
                    key: ValueKey<int>(_selectedRailIndex),
                    child: _allEntries[_selectedRailIndex].page,
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    // 宽屏(横屏 / 平板 / 桌面)走 WinUI3 侧栏布局;窄屏保持原有搜索栏 + 卡片列表。
    if (_isDesktop(context)) {
      return _buildWideLayout(context);
    }

    final isDesktop = _isDesktop(context);
    // 搜索激活时(桌面端展开 / 移动端全屏浮层)隐藏首页分组卡片,避免与搜索结果重复展示。
    final searchActive = isDesktop ? _searchExpanded : _searchOverlay != null;
    // 移动端浮层打开时隐藏首页这个折叠态搜索框:屏幕上只保留浮层里的那一个,
    // 避免"两个搜索框"的错位闪烁。用 Opacity(而非移除)保留其布局,
    // 使 startY 测量稳定、动画回落时能无缝对齐。
    final hideHomeSearchBar = !isDesktop && _searchOverlay != null;

    return MiuixScaffold(
      topBar: MiuixTopAppBar(
        title: 'flutter_miuix',
        scrollBehavior: _scrollBehavior,
        // 顶栏毛玻璃:透过顶栏虚化下方滚动内容(BackdropFilter 实时模糊身后 body)。
        blurred: true,
      ),
      content: (padding) => Material(
        type: MaterialType.transparency,
        child: MiuixScrollBehaviorListener(
          behavior: _scrollBehavior,
          child: ListView(
            padding: padding.copyWith(bottom: 32),
            children: [
              // --- 搜索栏 ---
              // 桌面端:内联可展开,输入框直接持有焦点。
              // 移动端:折叠态搜索框用 IgnorePointer 屏蔽,本身不参与焦点;
              //   点击由外层 GestureDetector 捕获,直接开全屏浮层(真焦点只在浮层内)。
              //   这样彻底避开焦点竞态,杜绝"展开关闭后再点无反应"。
              Padding(
                key: _searchBarKey,
                padding: const EdgeInsets.symmetric(horizontal: 12),
                child: Opacity(
                  opacity: hideHomeSearchBar ? 0.0 : 1.0,
                  child: _buildHomeSearchBar(context, isDesktop),
                ),
              ),

              // --- 组件列表 ---
              // 桌面端:搜索激活时直接移除(结果内联展示在搜索栏里)。
              // 移动端:保留在树里,透明度绑定搜索动画随浮层展开同步渐隐、
              //   收起时渐显——避免瞬间移除造成的突兀 / 残留。
              if (isDesktop) ...[
                if (!searchActive) ...[
                  const SizedBox(height: 16),
                  _buildEntriesCard(),
                ],
              ] else
                AnimatedBuilder(
                  animation: _searchAnim,
                  builder: (context, child) {
                    final t = Curves.easeOutCubic.transform(
                      _searchAnim.value.clamp(0.0, 1.0),
                    );
                    // 与浮层背景同一淡入节奏,卡片在浮层盖满前完成渐隐。
                    final fade = (t * 1.35).clamp(0.0, 1.0);
                    return IgnorePointer(
                      ignoring: fade > 0,
                      child: Opacity(opacity: 1 - fade, child: child),
                    );
                  },
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      const SizedBox(height: 16),
                      _buildEntriesCard(),
                    ],
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

// ──────────────────────────────────────────────
//  桌面端:搜索结果内联展开
// ──────────────────────────────────────────────

class _DesktopSearchResults extends StatelessWidget {
  const _DesktopSearchResults({
    required this.entries,
    required this.onCommitQuery,
  });
  final List<_DemoEntry> entries;

  /// 点击结果跳转前记录当前查询词到历史。
  final VoidCallback onCommitQuery;

  @override
  Widget build(BuildContext context) {
    if (entries.isEmpty) {
      return Padding(
        padding: const EdgeInsets.all(24),
        child: Center(
          child: Text(
            '未找到匹配的组件',
            style: TextStyle(
              fontSize: 14,
              color: MiuixTheme.of(context).colors.onSurfaceVariantSummary,
            ),
          ),
        ),
      );
    }
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        for (var i = 0; i < entries.length; i++) ...[
          if (i > 0) const IndentDivider(indent: 16),
          _SearchResultItem(entry: entries[i], onCommitQuery: onCommitQuery),
        ],
      ],
    );
  }
}

// ──────────────────────────────────────────────
//  移动端:全屏搜索浮层(OverlayEntry)
// ──────────────────────────────────────────────

class _MobileSearchPage extends StatelessWidget {
  const _MobileSearchPage({
    required this.query,
    required this.onQueryChanged,
    required this.onSearch,
    required this.onDismiss,
    required this.focusNode,
    required this.startY,
    required this.topInset,
    required this.animation,
    required this.entries,
    required this.history,
    required this.onHistoryTap,
    required this.onHistoryRemove,
    required this.onHistoryClear,
    required this.onCommitQuery,
  });

  final String query;
  final ValueChanged<String> onQueryChanged;
  final ValueChanged<String> onSearch;
  final VoidCallback onDismiss;
  final FocusNode focusNode;
  final double startY;

  /// 状态栏 / 刘海高度,展开后为系统栏预留的顶部空间。
  final double topInset;
  final AnimationController animation;
  final List<_DemoEntry> entries;

  /// 搜索历史(最近在前)。
  final List<String> history;
  final ValueChanged<String> onHistoryTap;
  final ValueChanged<String> onHistoryRemove;
  final VoidCallback onHistoryClear;

  /// 点击结果跳转前记录当前查询词到历史。
  final ValueChanged<String> onCommitQuery;

  @override
  Widget build(BuildContext context) {
    final colors = MiuixTheme.of(context).colors;

    // 输入框只构建一次的实例引用:AnimatedBuilder 每帧重建外层包裹,
    // 但同一 MiuixInputField 实例被复用,焦点 / 键盘不丢失。
    final inputField = MiuixInputField(
      query: query,
      onQueryChange: onQueryChanged,
      onSearch: onSearch,
      expanded: true,
      onExpandedChange: (_) {},
      focusNode: focusNode,
      label: '搜索组件…',
    );

    return AnimatedBuilder(
      animation: animation,
      builder: (context, _) {
        final t = Curves.easeOutCubic.transform(
          animation.value.clamp(0.0, 1.0),
        );
        // 搜索框从首页位置 startY 丝滑上移到状态栏下方(留 8px 呼吸间距)。
        final contentTop = startY + (topInset + 8 - startY) * t;
        // 页面背景与结果随动画淡入,略快于滑动以尽早盖住底层 app 栏。
        final fade = (t * 1.35).clamp(0.0, 1.0);

        return Stack(
          children: [
            // 页面背景:surface 色淡入 + 吸收点击,避免透传到底层首页。
            Positioned.fill(
              child: GestureDetector(
                behavior: HitTestBehavior.opaque,
                onTap: () {},
                child: ColoredBox(
                  color: colors.surface.withValues(alpha: fade),
                ),
              ),
            ),
            // 搜索框 + 结果,整体从 startY 上移。
            // 包一层透明 Material 满足 TextField 对 Material 祖先的要求;
            // 背景由下方淡入的 ColoredBox 提供,此层不再上色。
            Positioned(
              top: contentTop,
              left: 0,
              right: 0,
              bottom: 0,
              child: Material(
                type: MaterialType.transparency,
                child: Column(
                children: [
                  // ── 搜索输入框 + 取消(取消按钮随展开宽度渐入) ──
                  // 水平内边距从 24(首页搜索框位置)过渡到 12,配合上移
                  // 让搜索框从原位无缝滑入,杜绝横向跳变。
                  Padding(
                    padding: EdgeInsets.symmetric(horizontal: 24 - 12 * t),
                    child: Row(
                      children: [
                        Expanded(child: inputField),
                        ClipRect(
                          child: Align(
                            alignment: Alignment.centerLeft,
                            widthFactor: t,
                            child: Opacity(
                              opacity: fade,
                              child: GestureDetector(
                                onTap: animation.reverse,
                                child: Padding(
                                  padding: const EdgeInsets.symmetric(
                                    horizontal: 8,
                                    vertical: 10,
                                  ),
                                  child: Text(
                                    '取消',
                                    style: TextStyle(
                                      fontSize: 15,
                                      color: colors.primary,
                                    ),
                                  ),
                                ),
                              ),
                            ),
                          ),
                        ),
                      ],
                    ),
                  ),
                  // ── 分隔线 + 内容区(随动画淡入) ──
                  // 空查询 → 搜索历史;有查询 → 匹配结果。
                  Expanded(
                    child: Opacity(
                      opacity: fade,
                      child: Column(
                        children: [
                          const SizedBox(height: 8),
                          const Padding(
                            padding: EdgeInsets.symmetric(horizontal: 16),
                            child: MiuixHorizontalDivider(),
                          ),
                          Expanded(
                            child: query.trim().isEmpty
                                ? _SearchHistoryView(
                                    history: history,
                                    onTap: onHistoryTap,
                                    onRemove: onHistoryRemove,
                                    onClear: onHistoryClear,
                                  )
                                : _MobileSearchResults(
                                    entries: entries,
                                    onDismiss: onDismiss,
                                    onCommitQuery: () => onCommitQuery(query),
                                  ),
                          ),
                        ],
                      ),
                    ),
                  ),
                ],
                ),
              ),
            ),
          ],
        );
      },
    );
  }
}

/// 移动端搜索浮层的结果列表(分隔线上方由浮层直接构建)。
class _MobileSearchResults extends StatelessWidget {
  const _MobileSearchResults({
    required this.entries,
    required this.onDismiss,
    required this.onCommitQuery,
  });

  final List<_DemoEntry> entries;
  final VoidCallback onDismiss;

  /// 点击结果跳转前记录当前查询词到历史。
  final VoidCallback onCommitQuery;

  @override
  Widget build(BuildContext context) {
    final colors = MiuixTheme.of(context).colors;
    if (entries.isEmpty) {
      return Center(
        child: Text(
          '未找到匹配的组件',
          style: TextStyle(
            fontSize: 14,
            color: colors.onSurfaceVariantSummary,
          ),
        ),
      );
    }
    return ListView.builder(
      padding: const EdgeInsets.symmetric(vertical: 8),
      itemCount: entries.length,
      itemBuilder: (ctx, i) => _SearchResultItem(
        entry: entries[i],
        onDismiss: onDismiss,
        onCommitQuery: onCommitQuery,
      ),
    );
  }
}

// ──────────────────────────────────────────────
//  搜索历史视图
// ──────────────────────────────────────────────

/// 空查询时展示的搜索历史。最近在前,可点击重搜、单条删除、一键清空。
class _SearchHistoryView extends StatelessWidget {
  const _SearchHistoryView({
    required this.history,
    required this.onTap,
    required this.onRemove,
    required this.onClear,
  });

  final List<String> history;
  final ValueChanged<String> onTap;
  final ValueChanged<String> onRemove;
  final VoidCallback onClear;

  @override
  Widget build(BuildContext context) {
    final colors = MiuixTheme.of(context).colors;
    final textStyles = MiuixTheme.of(context).textStyles;

    if (history.isEmpty) {
      // 用 Column(min) + 水平居中,不依赖有界高度——桌面端 content 槽为无界高度,
      // 若用 Center 会试图撑满无限高度而崩溃。
      return Padding(
        padding: const EdgeInsets.symmetric(vertical: 48),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            MiuixIcon(
              vector: MiuixIcons.basic.search,
              size: 40,
              tint: colors.onSurfaceVariantSummary,
            ),
            const SizedBox(height: 12),
            Text(
              '暂无搜索历史',
              style: TextStyle(
                fontSize: 14,
                color: colors.onSurfaceVariantSummary,
              ),
            ),
          ],
        ),
      );
    }

    return ListView(
      // shrinkWrap 让本视图在两种上下文都可用:移动端浮层里被 Expanded 约束
      // (有界,滚动);桌面端在搜索栏 content 槽里无界(按内容高度收缩)。
      shrinkWrap: true,
      padding: const EdgeInsets.symmetric(vertical: 8),
      children: [
        // 标题行 + 清空按钮。
        Padding(
          padding: const EdgeInsets.fromLTRB(24, 8, 16, 4),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text('搜索历史', style: textStyles.title4),
              GestureDetector(
                behavior: HitTestBehavior.opaque,
                onTap: onClear,
                child: Padding(
                  padding: const EdgeInsets.symmetric(
                    horizontal: 8,
                    vertical: 4,
                  ),
                  child: Text(
                    '清空',
                    style: TextStyle(fontSize: 14, color: colors.primary),
                  ),
                ),
              ),
            ],
          ),
        ),
        for (final q in history)
          _HistoryItem(query: q, onTap: () => onTap(q), onRemove: () => onRemove(q)),
      ],
    );
  }
}

/// 单条搜索历史。左侧历史图标 + 查询词,右侧删除按钮。
class _HistoryItem extends StatelessWidget {
  const _HistoryItem({
    required this.query,
    required this.onTap,
    required this.onRemove,
  });

  final String query;
  final VoidCallback onTap;
  final VoidCallback onRemove;

  @override
  Widget build(BuildContext context) {
    final colors = MiuixTheme.of(context).colors;
    return MiuixBasicComponent(
      title: query,
      insideMargin: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      startAction: Padding(
        padding: const EdgeInsets.only(right: 12),
        child: MiuixIcon(
          vector: MiuixIcons.basic.search,
          size: 22,
          tint: colors.onSurfaceVariantSummary,
        ),
      ),
      endActions: [
        GestureDetector(
          behavior: HitTestBehavior.opaque,
          onTap: onRemove,
          child: Padding(
            padding: const EdgeInsets.only(left: 12),
            child: MiuixIcon(
              vector: MiuixIcons.basic.close,
              size: 18,
              tint: colors.onSurfaceVariantSummary,
            ),
          ),
        ),
      ],
      onClick: onTap,
    );
  }
}

// ──────────────────────────────────────────────
//  通用:搜索结果条目
// ──────────────────────────────────────────────

class _SearchResultItem extends StatelessWidget {
  const _SearchResultItem({
    required this.entry,
    this.onDismiss,
    this.onCommitQuery,
  });
  final _DemoEntry entry;
  /// 点击结果项后、跳转前触发的回调。移动端用于关闭搜索浮层。
  final VoidCallback? onDismiss;
  /// 点击结果跳转前记录当前查询词到历史。
  final VoidCallback? onCommitQuery;

  @override
  Widget build(BuildContext context) {
    return MiuixBasicComponent(
      title: entry.title,
      summary: entry.summary,
      insideMargin: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      startAction: Padding(
        padding: const EdgeInsets.only(right: 12),
        child: MiuixIcon(
          vector: MiuixIcons.extended.byName(entry.icon)!,
          size: 24,
        ),
      ),
      onClick: () {
        // 先取出 navigator(context 可能随浮层移除而失效),
        // 记录历史,再关闭浮层,最后跳转。
        final navigator = Navigator.of(context);
        onCommitQuery?.call();
        onDismiss?.call();
        navigator.push(
          MaterialPageRoute(builder: (_) => entry.page),
        );
      },
    );
  }
}

// ──────────────────────────────────────────────
//  数据模型 & 列表项
// ──────────────────────────────────────────────

class _DemoEntry {
  const _DemoEntry({
    required this.icon,
    required this.title,
    required this.summary,
    required this.page,
    this.keywords = const [],
  });

  final String icon;
  final String title;
  final String summary;
  final Widget page;
  final List<String> keywords;
}

/// 主页分组卡片内的一个分类入口项。
class _EntryTile extends StatelessWidget {
  const _EntryTile({required this.entry});
  final _DemoEntry entry;

  @override
  Widget build(BuildContext context) {
    return MiuixBasicComponent(
      title: entry.title,
      summary: entry.summary,
      insideMargin: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
      startAction: Padding(
        padding: const EdgeInsets.only(right: 12),
        child: MiuixIcon(
          vector: MiuixIcons.extended.byName(entry.icon)!,
          size: 24,
        ),
      ),
      onClick: () => Navigator.of(context).push(
        MaterialPageRoute(builder: (_) => entry.page),
      ),
    );
  }
}
1
likes
155
points
500
downloads

Documentation

API reference

Publisher

verified publishernekofun.top

Weekly Downloads

A HyperOS-style component library for Flutter, ported from miuix. 45+ widgets with squircle corners, Folme spring motion, liquid glass blur, and Monet dynamic colors.

Repository (GitHub)
View/report issues

Topics

#ui #widget #hyperos #miuix #material

License

Apache-2.0 (license)

Dependencies

cupertino_icons, dynamic_color, flutter, material_color_utilities

More

Packages that depend on flutter_miuix