Skip to content

Commit

Permalink
[flutter_web] NoteTree: add selected state
Browse files Browse the repository at this point in the history
  • Loading branch information
chen56 committed May 12, 2024
1 parent b283a54 commit 07e3afb
Show file tree
Hide file tree
Showing 5 changed files with 89 additions and 203 deletions.
2 changes: 1 addition & 1 deletion notes/flutter_web/lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ extension ContextExt on BuildContext {
DesignTokens get designTokens$ => DesignTokens(this);

RouteContext get route$ => YouRouter.of(this);
}
}
160 changes: 85 additions & 75 deletions notes/flutter_web/lib/routes/layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,10 @@ class RootLayout extends StatefulWidget {
}

class RootLayoutState extends State<RootLayout> {
final Value<int> 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: [
Expand Down Expand Up @@ -81,43 +78,16 @@ class _NoteTreeView extends StatefulWidget {

class _NoteTreeViewState extends State<_NoteTreeView> {
final Value<bool> includeDraft = false.signal();
final Value<To?> selected = (null as To?).signal();

@override
Widget build(BuildContext context) {
final route = context.route$;
static Widget _noteItem(BuildContext context, Value<To?> 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<ToNote>().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;
}
Expand All @@ -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<ToNote>().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 {
Expand Down Expand Up @@ -188,42 +198,6 @@ class ViewBar extends StatefulWidget {
}
}

class _ThemeView extends StatefulWidget {
const _ThemeView({required this.view});

final Value<String> view;

@override
State<StatefulWidget> createState() {
return _ThemeViewState();
}
}

class _ThemeViewState extends State<_ThemeView> {
final Value<bool> 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<ViewBar> {
final Value<String> view = "note_tree_view".signal();

Expand Down Expand Up @@ -270,15 +244,51 @@ class ViewBarState extends State<ViewBar> {
).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<String> view;

@override
State<StatefulWidget> createState() {
return _ThemeViewState();
}
}

class _ThemeViewState extends State<_ThemeView> {
final Value<bool> 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),
);
});
}
}
124 changes: 0 additions & 124 deletions notes/flutter_web/lib/routes/notes/layout/note.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;`
Loading

0 comments on commit 07e3afb

Please sign in to comment.