From 07e3afbbf7fb7745c891810e3b495948c8ebd2e0 Mon Sep 17 00:00:00 2001 From: Chen Peng Date: Sun, 12 May 2024 22:03:38 +0800 Subject: [PATCH] [flutter_web] NoteTree: add selected state --- notes/flutter_web/lib/app.dart | 2 +- notes/flutter_web/lib/routes/layout.dart | 160 ++++++++++-------- .../lib/routes/notes/layout/note.md | 124 -------------- notes/flutter_web/lib/routes/notes/page.dart | 4 +- .../lib/src/note/contents/outline.dart | 2 +- 5 files changed, 89 insertions(+), 203 deletions(-) diff --git a/notes/flutter_web/lib/app.dart b/notes/flutter_web/lib/app.dart index 3ae0b3bf..4b45f29e 100644 --- a/notes/flutter_web/lib/app.dart +++ b/notes/flutter_web/lib/app.dart @@ -29,4 +29,4 @@ extension ContextExt on BuildContext { DesignTokens get designTokens$ => DesignTokens(this); RouteContext get route$ => YouRouter.of(this); -} +} \ No newline at end of file diff --git a/notes/flutter_web/lib/routes/layout.dart b/notes/flutter_web/lib/routes/layout.dart index 59b851c6..9eced2d0 100644 --- a/notes/flutter_web/lib/routes/layout.dart +++ b/notes/flutter_web/lib/routes/layout.dart @@ -25,13 +25,10 @@ class RootLayout extends StatefulWidget { } class RootLayoutState extends State { - final Value navigationRail = 0.signal(); - @override Widget build(BuildContext context) { final route = context.route$; final colors = Theme.of(context).colorScheme; - return Scaffold( primary: true, appBar: AppBar(toolbarHeight: 38, title: Text("location: ${route.uri}"), foregroundColor: colors.primaryFixed, backgroundColor: colors.onPrimaryFixed, actions: [ @@ -81,43 +78,16 @@ class _NoteTreeView extends StatefulWidget { class _NoteTreeViewState extends State<_NoteTreeView> { final Value includeDraft = false.signal(); + final Value selected = (null as To?).signal(); - @override - Widget build(BuildContext context) { - final route = context.route$; + static Widget _noteItem(BuildContext context, Value selected, ToNote node, RouteContext route, bool includeDraft) { final colors = Theme.of(context).colorScheme; - routes.routes_root.expandTree(true, level: 2); - return Watch((context) { - final notes = routes.routes_notes.toList(includeThis: false).cast().where((e) { - return e.containsPage() && e.parent.expand && (includeDraft.value || e.containsPublishNode(includeThis: true)); - }); -//great for ide like layouts - return Column( - children: [ - Container( - color: colors.surfaceContainer, - child: OverflowBar(alignment: MainAxisAlignment.end, children: [ - IconButton(tooltip: "Expand all", icon: const Icon(Icons.expand, size: 24), iconSize: 24, onPressed: () => notes.forEach((i) => i.expandTree(true))), - IconButton(tooltip: "Collapse all", icon: const Icon(Icons.compress), iconSize: 24, onPressed: () => notes.forEach((i) => i.expandTree(false))), - IconButton(tooltip: "Include draft", icon: const Icon(Icons.drafts_outlined), iconSize: 24, selectedIcon: const Icon(Icons.drafts), isSelected: includeDraft.value, onPressed: () => includeDraft.value = !includeDraft.value), - IconButton(tooltip: "Hidden", icon: const Icon(Icons.horizontal_rule), iconSize: 24, onPressed: () => widget.view.value = ""), - ]), - ), - const Divider(), - ListView( - children: notes.map((e) => _noteItem(e, route, includeDraft.value)).toList(), - ).expanded$(), - ], - ).constrainedBox$( - constraints: const BoxConstraints.tightFor(width: 350), - ); - }); - } + final rootNoteLevel = routes.routes_notes.level + 1; - static Widget _noteItem(ToNote node, RouteContext route, bool includeDraft) { click() { if (node.isLeafPage) { route.to(node.toUri()); + selected.value = node; } else { node.expand = !node.expand; } @@ -131,16 +101,56 @@ class _NoteTreeViewState extends State<_NoteTreeView> { : "︎︎︎▶"; String title = "$iconExtend ${node.label}"; - title = title.padLeft((node.level * 2) + title.length); + title = title.padLeft(((node.level - rootNoteLevel) * 2) + title.length); String publishLabel = ""; if (node.isLeafPage) { publishLabel = node.isPublish ? "(已发布)" : "(草稿)"; } - return Align( - alignment: Alignment.centerLeft, - child: TextButton(onPressed: click, child: Text('$title$publishLabel')), + return ListTile( + title: Text('$title$publishLabel'), + minTileHeight: 36, + minVerticalPadding: 6, + minLeadingWidth: 0, + selected: selected.value == node, + selectedTileColor: colors.surfaceContainerHighest, + onTap: click, ); } + + @override + Widget build(BuildContext context) { + routes.routes_root.expandTree(true, level: 2); + return Watch((context) { + final route = context.route$; + final colors = Theme.of(context).colorScheme; + + final notes = routes.routes_notes.toList(includeThis: false).cast().where((e) { + return e.containsPage() && e.parent.expand && (includeDraft.value || e.containsPublishNode(includeThis: true)); + }); + return Column( + children: [ + Container( + color: colors.surfaceContainerHighest, + child: OverflowBar(alignment: MainAxisAlignment.end, children: [ + IconButton(tooltip: "Expand all", icon: const Icon(Icons.expand, size: 24), iconSize: 24, onPressed: () => notes.forEach((i) => i.expandTree(true))), + IconButton(tooltip: "Collapse all", icon: const Icon(Icons.compress), iconSize: 24, onPressed: () => notes.forEach((i) => i.expandTree(false))), + IconButton(tooltip: "Include draft", icon: const Icon(Icons.drafts_outlined), iconSize: 24, selectedIcon: const Icon(Icons.drafts), isSelected: includeDraft.value, onPressed: () => includeDraft.value = !includeDraft.value), + IconButton(tooltip: "Hidden", icon: const Icon(Icons.horizontal_rule), iconSize: 24, onPressed: () => widget.view.value = ""), + ]), + ), + const Divider(height: 1), + Material( + child: ListView( + padding: EdgeInsets.zero, + children: [...notes.map((e) => _noteItem(context, selected, e, route, includeDraft.value)).toList()], + ), + ).expanded$(), + ], + ).constrainedBox$( + constraints: const BoxConstraints.tightFor(width: 350), + ); + }); + } } extension _NoteTreeNode on To { @@ -188,42 +198,6 @@ class ViewBar extends StatefulWidget { } } -class _ThemeView extends StatefulWidget { - const _ThemeView({required this.view}); - - final Value view; - - @override - State createState() { - return _ThemeViewState(); - } -} - -class _ThemeViewState extends State<_ThemeView> { - final Value includeDraft = false.signal(); - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - return Watch((context) { - return Column( - children: [ - Container( - color: colors.surfaceContainer, - child: OverflowBar(alignment: MainAxisAlignment.end, children: [ - IconButton(tooltip: "Hidden", icon: const Icon(Icons.horizontal_rule), iconSize: 24, onPressed: () => widget.view.value = ""), - ]), - ), - const Divider(), - - ], - ).constrainedBox$( - constraints: const BoxConstraints.tightFor(width: 350), - ); - }); - } -} - class ViewBarState extends State { final Value view = "note_tree_view".signal(); @@ -270,15 +244,51 @@ class ViewBarState extends State { ).flexible$(), ], ), - const VerticalDivider(), + const VerticalDivider(width: 1), Watch((context) { return _NoteTreeView(view: view).offstage$(offstage: view.value != "note_tree_view"); }), Watch((context) { return _ThemeView(view: view).offstage$(offstage: view.value != "theme_view"); }), + const VerticalDivider(width: 1), ], )); }); } } + +class _ThemeView extends StatefulWidget { + const _ThemeView({required this.view}); + + final Value view; + + @override + State createState() { + return _ThemeViewState(); + } +} + +class _ThemeViewState extends State<_ThemeView> { + final Value includeDraft = false.signal(); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Watch((context) { + return Column( + children: [ + Container( + color: colors.surfaceContainer, + child: OverflowBar(alignment: MainAxisAlignment.end, children: [ + IconButton(tooltip: "Hidden", icon: const Icon(Icons.horizontal_rule), iconSize: 24, onPressed: () => widget.view.value = ""), + ]), + ), + const Divider(), + ], + ).constrainedBox$( + constraints: const BoxConstraints.tightFor(width: 350), + ); + }); + } +} diff --git a/notes/flutter_web/lib/routes/notes/layout/note.md b/notes/flutter_web/lib/routes/notes/layout/note.md index 1aadabcc..1650292e 100644 --- a/notes/flutter_web/lib/routes/notes/layout/note.md +++ b/notes/flutter_web/lib/routes/notes/layout/note.md @@ -377,127 +377,3 @@ - **Stack & Positioned**: 可以通过调整Positioned的位置属性将子Widget移出屏幕可视范围,或者堆叠顺序(z-index)来控制覆盖关系,间接达到隐藏或显示的效果。 - **SizedBox.shrink**: 可以用于替代某个Widget并将其大小“缩小”至零,从而在视觉上隐藏,但注意它仍然会参与布局过程。 - **CustomSingleChildLayout/CustomMultiChildLayout**:通过自定义布局逻辑,可以更加灵活地控制子Widget的布局和可见性,比如在特定条件下改变子Widget的位置使其超出视口范围。 - - - - - -### 填充留白 - -- **Divider** - - **ListTile.divideTiles** another approach to dividing widgets in a list. - - **PopupMenuDivider** which is the equivalent but for popup menus. - - **VerticalDivider** which is the vertical analog of this widget. -- **Placeholder**:常用于占位,在加载数据或资源尚未准备就绪时显示临时内容。它可以作为一个视觉提示,提醒用户该位置的内容随后会被填充。它不具有自适应尺寸的功能,但可以设置默认尺寸,并且常与异步数据加载结合使用,确保即使在真实内容加载前也有良好的用户体验。 -- **Spacer**:已在`Flex`中提到,主要用于分配布局中的空白空间。通常用于`Row`、`Column`或`Flex`布局中,作为填充空间的占位符。当希望某个方向上的剩余空间被均匀分配时,可以放置一个或多个`Spacer`。它没有自身的尺寸,而是根据`Flex`布局规则来决定占用的空间大小。 - -## 高级特性复杂布局 - -- **AppBar** -- **Card**:Card组件虽然不是纯粹的布局组件,但因其提供了统一的矩形框样式和阴影效果,常用于构建卡片式的布局单元,特别是在列表和网格布局中。 -- **GridView** 网格布局,可以创建类似表格或卡片列表的效果,支持横向或纵向滚动。 -- **ListView&ListBody**:可在 ListView 的头部或尾部使用 ListBody 来渲染一些固定的内容,这样可以充分利用两者的优势。 - - **ListBody**: 列表容器,不支持滚动、懒加载等高级特性。 - - 【原理】通过 Flex 布局算法来决定子 Widget 的位置。它只负责布局,不提供滚动功能。 - - 【场景】适用于列表项数量较少、不需要滚动功能的场景,比如简单的菜单列表。 - - **ListView**: 列表容器,还支持滚动、懒加载、头尾部件等丰富的功能。 - - 【原理】内部使用 Sliver 技术来实现滚动效果。它不仅提供列表布局,还支持滚动、懒加载等功能。 - - 【场景】适用于需要处理大量列表项、支持滚动和懒加载的场景,比如新闻列表、电商列表等。 -- **Scaffold**:Scaffold是Material Design风格应用的基础布局组件,包含了app bar、body、bottomNavigationBar、drawer等常见布局元素,有助于快速构建标准的Material应用界面。 -- **Table**:表格布局,可以灵活定义行和列的数量及内容。 - - **DataTable**: Table的增强版,为表格提供了更丰富的样式和交互功能,适合展示具有表头、索引列和操作列的数据。 -- **堆叠&可切换** - - **PageView**:用于实现滑动页面效果。 - - 【场景】幻灯片、相册浏览、引导页、广告轮播、仪表盘切换 - - **Stack**:Stack允许子组件堆叠展示,支持定位(alignment)和z轴排序(index),可以用于制作悬浮按钮、叠加图片、标签页指示器等效果。 - - **Positioned**: 在Stack中结合Positioned使用,可以更精确地控制子组件在Stack中的绝对位置。 - - **IndexedStack**: 内部包了个Stack,允许在一组子组件中切换显示,类似TabView效果,通过索引值控制显示指定子组件。 - - 【场景】适合于页面数量相对较少且需要快速切换、同时保持页面状态(比如滚动位置)的场景,它不包含任何动画效果,页面切换是瞬间完成的。 - - 【原理】IndexedStack是一个能够记住其子Widget状态的Stack。当你改变索引时,它不会销毁和重建未显示的子Widget,而是简单地改变可见性,从而保留之前页面的状态。 - - **TabView**: 是一个用于实现带标签页导航的Widget,常与TabBar配合使用,提供平滑的页面切换动画。提供了丰富的自定义选项,比如页面切换动画、指示器样式等。 - - 【原理】当用户在不同的tab之间切换时,TabView默认会销毁离开的页面并重建进入的页面,这意味着页面状态不会被保留(除非额外采取措施,比如使用AutomaticKeepAliveClientMixin)。 - - 【场景】更适用于有多个固定分类内容展示的场景,比如应用的主页有新闻、视频、我的等几个固定的tab。 - -## 滚动性 - -- **ScrollView**: - - **SingleChildScrollView**: - - 【原理】 - - SingleChildScrollView 本身不提供任何布局能力,需要手动包裹一个布局 Widget(如 Column、Row 等) - - 简单,性能好 - - 【场景】SingleChildScrollView 适用于需要滚动的单个 Widget,比如表单、简单的列表等。 - - **BoxScrollView**: - - 【原理】 - - 基于 Viewport 和 RenderBox 技术实现,内部会直接处理滚动逻辑。 - - 其父类是ScrollView 而ListView和GridView是BoxScrollView子类。 - - 【场景】 - - 适用于普通的列表或网格滚动场景,如新闻列表、商品列表等。 - - **CustomScrollView**:LayoutId 和CustomScrollView结合使用,可以实现自定义的滚动视图布局,如多个不同滚动速度的列表视图。 - - 【原理】 - - 基于 Sliver 技术实现,内部使用多个 Sliver 组件来共同完成滚动效果。可以自由组合各种 Sliver 组件来构建复杂的滚动界面。 - - 【场景】 - - 适用于需要实现复杂滚动效果的场景,如带有吸顶效果的应用栏、嵌套的列表/网格等。 - - **NestedScrollView**:在同一滚动视图中嵌套其他滚动视图,如顶部有一个固定的AppBar和底部有一个可滚动的列表。 -- **滚动相关** - - **ScrollNotification** and **NotificationListener**: which can be used to watch the scroll position without using a [ScrollController]. - - **ScrollController**: 记得dispose - - **Viewport** 用于表示可视区域内的内容。Viewport 通常与可滚动组件配合使用,用于控制和管理可滚动内容的显示。 - - 【原理】 - - 确定可视区域:Viewport 负责计算和管理当前可视区域的大小和位置。 它会根据屏幕尺寸和滚动位置来确定可视区域的范围。 - - 管理可视内容:Viewport 会决定哪些子组件应该被渲染和显示在可视区域内。 它会根据可视区域的范围来选择需要渲染的子组件,以提高性能。 - - 处理滚动:Viewport 会监听用户的滚动输入,并根据滚动距离更新可视区域的位置。 它负责将滚动事件转化为对可视内容的适当调整。 - - 提供约束条件:Viewport 会将可视区域的大小作为约束条件传递给子组件。 子组件可以根据这些约束条件来决定自身的大小和布局。 - - 【场景】 - - ListView 和 GridView 都是基于 Viewport 实现的可滚动组件。 它们利用 Viewport 来确定当前可视区域内应该显示哪些子组件,从而实现高效的滚动体验。 - - CustomScrollView 基于 Viewport 和 Sliver 技术实现。Viewport 负责管理可视区域内的 Sliver 组件,如 SliverList、SliverGrid 等,从而构建出复杂的滚动界面。 - - SingleChildScrollView 也使用 Viewport 来管理其单个子组件的滚动。它可以确保子组件在可视区域内正确显示和滚动。 - - PageView 是一种基于 Viewport 实现的特殊可滚动组件,它用于实现页面级别的滚动和切换。Viewport 在这里负责管理当前可视的页面,并在用户滑动时切换到下一个页面。 - - Expanded 和 Flexible这两个布局组件也与 Viewport 相关,它们用于控制子组件在可用空间内的分布和增长。当子组件位于可滚动视图中时,Viewport 提供的约束条件会影响 Expanded 和 Flexible 的行为。 - -- **已提及的其他可滚动组件** - - GridView - - ListView - - PageView -- **ScrollPhysics** - - **简介**: 滚动物理模型 - - **BouncingScrollPhysics**: 用于实现类似 iOS 上的弹性滚动效果。 - - 弹性滚动:当用户滚动到列表的顶部或底部时,会出现一种"弹性"的感觉,给用户一种"拉伸"的体验。这种效果可以让滚动操作更加自然和生动。 - - 阻尼效果:当用户松开手指时,滚动会逐渐减速并停下来,而不是立即停止。这种阻尼效果让滚动操作更加顺滑自然。 - - 过度滚动:当用户滚动到列表的顶部或底部时,列表会稍微"超出"一点,然后再返回到正常位置。这种过度滚动也是 iOS 上常见的效果,增加了视觉上的连贯性。 - - **ClampingScrollPhysics**: 不允许过度滚动,列表滚动到顶部或底部时会立即停止。 - - **AlwaysScrollableScrollPhysics**: 始终允许滚动,即使列表项的总高度小于列表容器的高度。 - - **NeverScrollableScrollPhysics**: 禁用滚动功能,列表项无法滚动。 - -## **自定义Custom** - -- **CustomSingleChildLayout**: 实现SingleChildLayoutDelegate自定义子组件的布局。 -- **CustomMultiChildLayout**: 实现MultiChildLayoutDelegate自定义子组件的布局。 -- **CustomPainter**: 虽然主要用于自定义绘画,但在实现复杂自定义布局时也发挥着重要作用,可以通过`Canvas` API实现精确的像素级布局。 -- **Flow**: 可以指定`FlowDelegate`实现自己的绘制逻辑,你可以根据子 Widget 的大小和位置来动态调整子 Widget 的位置和大小。这个 Widget 非常适合用于实现复杂的布局需求,例如文本编辑器、绘图工具等。 -- **RenderObjectWidget** 和 **RenderBox**:虽然它们不是直接的布局组件,但理解和使用RenderObjectWidget和RenderBox对于自定义布局逻辑至关重要。通过继承和自定义这些底层渲染对象,可以创建高度自定义的布局效果。 -- **已提及的其他可自定义的组件** - - CustomClipper - - CustomScrollView - -## **Sliver系列** - -- **介绍** - - 如 `SliverAppBar`、`SliverList`、`SliverGrid` 等,专为`CustomScrollView`设计,用于高效实现滚动视图布局。 -- **ShrinkWrappingViewport**:类似于ListView,但它的大小可以根据其子组件的大小进行收缩,而不是根据父容器的大小进行填充。 - -## **布局&动画** - -- **AlignTransition** 和 **PositionedTransition**: - - 这两个组件是对`Align`和`Positioned`组件的动画版本,可以为子组件的对齐或定位提供平滑过渡动画。 -- **Hero动画**:虽非布局组件,但与布局转换相关。它在不同路由或页面间实现元素共享及动画过渡,常用于Material Design中的共享元素过渡效果。 -- **AnimatedContainer**:AnimatedContainer组件在更改其尺寸、颜色、边距等属性时,会为其变化提供平滑的动画效果,非常适合构建动态布局变化的场景。 - -## 其他参考 - -- StatefulWidget在最外层会随着屏幕大小变化自动build -- 自适应尺寸:/flutter/examples/api/lib/widgets/framework/build_owner.0.dart - -### 获取尺寸的各种方法 - -- 组件尺寸:WidgetsBinding.instance.addPostFrameCallback中: (context.findRenderObject() as RenderBox).size -- 屏幕宽度:`double screenWidth = MediaQuery.of(context).size.width;` diff --git a/notes/flutter_web/lib/routes/notes/page.dart b/notes/flutter_web/lib/routes/notes/page.dart index 59d4bf6b..7681c494 100644 --- a/notes/flutter_web/lib/routes/notes/page.dart +++ b/notes/flutter_web/lib/routes/notes/page.dart @@ -4,9 +4,9 @@ import 'package:you_flutter/note.dart'; @NoteAnnotation(label: "笔记") void build(BuildContext context, Cell print) { print(const MD(r''' -# home +# flutter 笔记 -本页面应该是不暴露的 ,但现在并未做任何限制,通过 / 可以看到 +可选择左侧笔记阅读 ''')); } diff --git a/packages/you_flutter/lib/src/note/contents/outline.dart b/packages/you_flutter/lib/src/note/contents/outline.dart index 09107188..8fe8f4e0 100644 --- a/packages/you_flutter/lib/src/note/contents/outline.dart +++ b/packages/you_flutter/lib/src/note/contents/outline.dart @@ -109,7 +109,7 @@ class OutlineTreeView extends StatelessWidget { // 一页一个链接 Widget headLink(OutlineNode node) { var link2 = TextButton( - style: ButtonStyle(padding: WidgetStateProperty.all(const EdgeInsets.all(2))), + style: TextButton.styleFrom().copyWith(padding: WidgetStateProperty.all(const EdgeInsets.all(2))), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [