flutter 非常用组件整理 第三篇
视频
https://www.bilibili.com/video/BV1XS411c7Rj/
前言
原文 https://ducafecat.com/blog/lesser-known-flutter-widgets-03
本文是非常用组件的第三讲,介绍了一些不为人知但却能大幅提升Flutter应用UI效果和功能的高级组件,包括FadeInImage、GridPaper、Hero等,为开发者带来更丰富的UI设计可能。
Flutter, 组件, UI开发, 高级组件, FadeInImage, GridPaper, Hero
参考
正文
FadeInImage
FadeInImage 组件用于在加载图片时实现淡入效果,它可以在网络图片或资源图片加载过程中显示一个占位图,并在图片加载完成后淡入显示。这样可以提高用户体验,避免出现图片加载时的空白区域。下面是一个简单的例子:
Widget _mainView() {
return const FadeInImage(
placeholder: AssetImage('assets/app_icon.png'), // 占位图
image: NetworkImage(
'https://ducafecat.oss-cn-beijing.aliyuncs.com/podcast/2024/07/8f08bba2bf28bc26c38394cf5591b29d.png'), // 目标图片
fadeInDuration: Duration(milliseconds: 300), // 淡入时长
fadeInCurve: Curves.easeIn, // 淡入曲线
fit: BoxFit.cover, // 图片填充方式
);
}
在这个例子中:
placeholder
属性指定了占位图,这里使用了一个 logo png 图片。image
属性指定了目标图片,这里使用了一个网络图片。fadeInDuration
属性指定了淡入持续时间,这里设置为 300 毫秒。fadeInCurve
属性指定了淡入曲线,这里使用了Curves.easeIn
缓慢淡入的效果。fit
属性指定了图片的填充方式,这里使用了BoxFit.cover
将图片填充满整个容器。
当这段代码运行时,在目标图片加载完成之前,会先显示占位图并淡入,提供良好的用户体验。您可以根据实际需求调整各个属性的值。
FadeInImage 组件除了可以用于网络图片,也可以用于本地资源图片。如果您需要在图片加载过程中显示其他类型的占位控件,也可以使用 placeholder
属性来指定。总之,FadeInImage 是一个非常实用的 Flutter 组件,可以帮助您轻松实现图片的淡入效果。
GridPaper
GridPaper 是一个用于在 Flutter 应用程序中显示网格纸的小部件。它可以用于创建具有网格线的背景,以帮助设计和布局界面。下面是一个简单的例子:
Widget _mainView() {
return GridPaper(
color: Colors.grey, // 网格线颜色
divisions: 8, // 每个单元格分为8个小格
subdivisions: 1, // 每个小格子再细分1次
child: Container(
color: Colors.white, // 背景颜色
// 在网格纸上放置其他小部件
child: const Center(
child: Text('GridPaper Example'),
),
),
);
}
在这个例子中:
color
属性指定了网格线的颜色,这里使用了一种浅灰色。divisions
属性指定了每个单元格被分成多少个小格子,这里设置为 8。subdivisions
属性指定了每个小格子被细分为多少次,这里设置为 1。child
属性指定了要在网格纸上放置的其他小部件,这里是一个居中的文本。
当这段代码运行时,您将看到一个白色背景上有灰色网格线的 UI 元素。网格线可以帮助您快速定位和对齐页面上的其他小部件。
除了上述属性,GridPaper 还提供了其他一些属性,如 crossAxisCount
、mainAxisCount
和 subdivisionCount
。您可以根据实际需求调整这些属性,以创建更复杂的网格纸样式。
Hero
Hero 组件用于在页面之间实现平滑的过渡动画,通常用于实现点击图片放大的效果。当用户点击一个带有 Hero 组件的图片时,该图片会平滑地放大并过渡到新页面。下面是一个简单的例子:
主界面
Widget _mainView() {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const DetailPage()),
);
},
child: Hero(
tag: 'imageHero', // 必须在多个页面使用同样的tag
child: Center(
child: SizedBox(
width: 200,
child: Image.network(
'https://ducafecat.oss-cn-beijing.aliyuncs.com/podcast/2024/07/8f08bba2bf28bc26c38394cf5591b29d.png'),
),
),
),
);
}
详情页
class DetailPage extends StatelessWidget {
const DetailPage({
super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Detail')),
body: Center(
child: Hero(
tag: 'imageHero',
child: Image.network(
'https://ducafecat.oss-cn-beijing.aliyuncs.com/podcast/2024/07/8f08bba2bf28bc26c38394cf5591b29d.png'),
),
),
);
}
}
当点击小图后,会动画切入详情页图片。
在这个例子中:
- 主页面有一个
GestureDetector
包裹的Hero
组件,用于监听图片的点击事件。 tag
属性指定了这个 Hero 组件的标识,在多个页面中必须使用相同的 tag。- 当用户点击图片时,Navigator 会推入一个新的
DetailPage
页面,该页面也包含一个相同 tag 的Hero
组件。 - 在页面切换过程中,Flutter 会自动执行图片的放大过渡动画。
这样就实现了从主页面到详情页面的图片平滑过渡效果。Hero 组件不仅可以用于图片,也可以用于其他类型的 UI 元素,如文本、图标等。只要在多个页面中使用相同的 tag,Flutter 就会自动处理这些元素之间的过渡动画。
通过使用 Hero 组件,您可以轻松为应用程序添加丰富的过渡动画效果,提升用户体验。您还可以结合其他 Flutter 动画相关的组件,进一步优化动画效果。
KeepAlive
KeepAlive 组件用于保持子组件的状态,防止其在不可见时被销毁。这在某些场景下非常有用,比如当用户在应用程序中滚动到不同的页面时,我们希望之前的页面状态能够保留下来。
class _ChatPageState extends State<ChatPage>
with AutomaticKeepAliveClientMixin {
bool get wantKeepAlive => true;
Widget build(BuildContext context) {
super.build(context);
return _ChatViewGetX();
}
}
- 混入 AutomaticKeepAliveClientMixin
- 重写 wantKeepAlive => true
ListBody
ListBody 是一个用于创建垂直或水平排列的子组件列表的 Widget。它通常用于在 ListView 或 Column/Row 中作为子组件使用,用于管理子组件的布局和滚动行为。
下面是一个简单的示例:
Widget _mainView() {
return Column(
children: [
ListBody(
mainAxis: Axis.vertical,
children: [
Container(
color: Colors.blue,
height: 100,
child: const Center(
child: Text('Item 1'),
),
),
Container(
color: Colors.green,
height: 100,
child: const Center(
child: Text('Item 2'),
),
),
Container(
color: Colors.red,
height: 100,
child: const Center(
child: Text('Item 3'),
),
),
],
),
],
);
}
在这个例子中:
- 我们创建了一个
ListBody
组件,并将其设置为Scaffold
的body
。 mainAxis
属性指定了子组件的排列方向,这里我们选择了垂直排列 (Axis.vertical
)。children
属性是一个List<Widget>
,包含了三个Container
作为子组件。
运行这个应用程序,您将看到三个颜色不同的容器垂直排列在屏幕上。
与 ListView
或 Column/Row
不同,ListBody
本身不会提供任何滚动功能。它只是管理子组件的布局和排列。如果您的子组件超出了屏幕范围,您需要将 ListBody
包裹在一个可滚动的 Widget 中,比如 ListView
或 SingleChildScrollView
。
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('ListBody Example'),
),
body: ListView(
children: [
ListBody(
mainAxis: Axis.vertical,
children: [
// 子组件
],
),
],
),
);
}
总之,ListBody
是一个非常灵活的 Widget,可以帮助您轻松地管理和排列子组件。它在需要自定义列表布局和滚动行为的场景下非常有用。
Listener
好的,我来为您介绍一下 Flutter 中的 Listener
组件。
Listener
是一个用于处理各种输入事件的 Widget。它可以监听诸如点击、滚动、拖动等事件,并提供相应的回调函数来处理这些事件。
定义:
const Listener({
super.key,
this.onPointerDown,
this.onPointerMove,
this.onPointerUp,
this.onPointerHover,
this.onPointerCancel,
this.onPointerPanZoomStart,
this.onPointerPanZoomUpdate,
this.onPointerPanZoomEnd,
this.onPointerSignal,
this.behavior = HitTestBehavior.deferToChild,
super.child,
});
下面是一个简单的示例:
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Listener Example'),
),
body: Listener(
onPointerDown: (details) {
print('Pointer down at ${details.position}');
},
onPointerMove: (details) {
print('Pointer moved to ${details.position}');
},
onPointerUp: (details) {
print('Pointer up at ${details.position}');
},
child: GestureDetector(
onTap: () {
print('Tapped!');
},
child: Container(
color: Colors.grey[200],
padding: EdgeInsets.all(16.0),
child: Center(
child: Text('Click or drag me'),
),
),
),
),
);
}
控制台输出
flutter: Pointer moved to Offset(140.3, 500.7)
flutter: Pointer moved to Offset(155.7, 520.7)
flutter: Pointer moved to Offset(174.3, 531.7)
flutter: Pointer moved to Offset(195.7, 534.7)
flutter: Pointer moved to Offset(220.7, 528.7)
flutter: Pointer moved to Offset(243.3, 521.0)
flutter: Pointer moved to Offset(259.7, 511.3)
flutter: Pointer moved to Offset(265.7, 500.3)
flutter: Pointer moved to Offset(266.3, 498.7)
flutter: Pointer up at Offset(266.3, 498.7)
flutter: Pointer down at Offset(209.0, 261.3)
flutter: Pointer up at Offset(209.0, 261.3)
flutter: Tapped!
flutter: Pointer down at Offset(303.0, 272.7)
flutter: Pointer up at Offset(303.0, 272.7)
在这个示例中:
- 我们创建了一个
Listener
组件,并将其作为Scaffold
的body
。 - 在
Listener
中,我们定义了三个回调函数:onPointerDown
: 当指针按下时触发onPointerMove
: 当指针移动时触发onPointerUp
: 当指针抬起时触发
- 在这些回调函数中,我们打印出指针的位置。
- 在
Listener
的child
中,我们有一个GestureDetector
组件,它处理点击事件。当用户点击容器时,会打印出 "Tapped!"。
运行这个应用程序,您将看到一个灰色的容器。当您点击或拖动这个容器时,控制台会输出指针的位置和"Tapped!"消息。
Listener
组件可以监听各种输入事件,包括:
- 指针事件: 点击、拖动、滚动等
- 键盘事件: 按键按下、抬起、输入等
- 焦点事件: 获得焦点、失去焦点等
通过使用 Listener
,您可以自定义应用程序的交互行为,并实现更复杂的输入处理逻辑。例如:
- 实现自定义的拖放功能
- 监听键盘输入并执行相应的操作
- 处理触摸屏上的手势交互
- 检测用户是否离开应用程序的焦点
需要注意的是,在使用 Listener
时,您需要小心处理事件冲突和性能问题。有时候,使用更高级别的 Widget,如 GestureDetector
或 InteractiveViewer
,可能会更方便和高效。
Magnifier
Magnifier
是一个强大的组件,它可以放大显示屏上的某一区域,以帮助用户放大查看细节。它通常用于辅助视力较弱的用户或需要仔细观察某些细节的场景。
下面是一个简单的示例:
Widget _mainView() {
return Stack(
alignment: AlignmentDirectional.center,
children: [
Image.network(
'https://ducafecat.oss-cn-beijing.aliyuncs.com/podcast/2024/07/8f08bba2bf28bc26c38394cf5591b29d.png'),
const Magnifier(
additionalFocalPointOffset: Offset(10, 10),
size: Size(300, 300),
),
],
);
}
在这个示例中:
- 我们创建了一个
Magnifier
组件,并将其作为Scaffold
的body
。 - 我们设置了
Magnifier
的size
属性,指定放大镜的大小为 200x200 像素。 - 我们还设置了
offset
属性,指定放大镜的位置为距离屏幕左上角 50x50 像素的位置。
运行这个应用程序,您将看到一个 200x200 像素的放大镜,它会放大位于屏幕左上角 50x50 像素位置的图片区域。您可以通过拖动放大镜来查看图片的不同部分。
Magnifier
组件还支持以下属性:
clipBehavior
: 控制放大镜的裁剪行为。enablePanning
: 是否允许用户拖动放大镜。elevation
: 设置放大镜的阴影效果。color
: 设置放大镜的背景颜色。strokeWidth
: 设置放大镜边框的宽度。strokeColor
: 设置放大镜边框的颜色。
通过这些属性,您可以自定义 Magnifier
的外观和行为,以满足不同的使用场景。
需要注意的是,Magnifier
组件并不适用于所有情况。在某些情况下,使用 GestureDetector
和 Transform
等组件可能会更加灵活和高效。您需要根据具体需求来选择合适的组件。
MenuAnchor
MenuAnchor
是一个强大的组件,它可以在应用程序中创建上下文菜单或弹出菜单。它允许您将菜单项附加到特定的 UI 元素上,并在用户触发该元素时显示菜单。
下面是一个简单的示例:
Widget _mainView() {
return Center(
child: MenuAnchor(
menuChildren: [
MenuItemButton(
child: const Text('Option 1'),
onPressed: () {
print('Option 1 selected');
},
),
MenuItemButton(
child: const Text('Option 2'),
onPressed: () {
print('Option 2 selected');
},
),
MenuItemButton(
child: const Text('Option 3'),
onPressed: () {
print('Option 3 selected');
},
),
],
builder: (context, controller, child) {
return ElevatedButton(
onPressed: controller.open,
child: const Text('Open Menu'),
);
},
),
);
}
在这个示例中:
- 我们创建了一个
MenuAnchor
组件,并将其作为Scaffold
的body
。 - 在
menuChildren
属性中,我们定义了三个MenuItemButton
组件,分别代表三个菜单选项。每个选项都有一个onPressed
回调函数,在用户选择时输出相应的消息。 - 在
builder
属性中,我们创建了一个ElevatedButton
,当用户点击它时,会触发controller.open
方法,打开菜单。
运行这个应用程序,您将看到一个"Open Menu"按钮。当您点击该按钮时,会弹出一个包含三个选项的菜单。选择任意一个选项,控制台都会输出相应的消息。
MenuAnchor
组件还提供了以下属性:
menuDirection
: 设置菜单的弹出方向,如向上、向下、向左或向右。menuConstraints
: 设置菜单的大小约束。menuStyle
: 设置菜单的样式,如背景色、阴影等。useButtonStyle
: 指定是否使用按钮样式渲染菜单项。controller
: 提供对菜单状态的编程控制。
通过这些属性,您可以完全自定义菜单的外观和行为,以满足您的应用程序需求。
需要注意的是,MenuAnchor
组件适用于需要上下文菜单或弹出菜单的场景。如果您需要更简单的菜单实现,可以考虑使用 DropdownButton
或 PopupMenuButton
等组件。
SegmentedButton
SegmentedButton
是一个非常有用的小部件,它可以让您在一组相关选项之间进行选择。它通常用于创建选项卡、切换按钮或者其他需要在几个选项之间切换的UI场景。
下面是一个简单的示例:
String _selectedOption = 'option1';
Widget _mainView() {
return SegmentedButton<String>(
segments: const <ButtonSegment<String>>[
ButtonSegment<String>(
value: 'option1',
label: Text('Option 1'),
icon: Icon(Icons.home),
),
ButtonSegment<String>(
value: 'option2',
label: Text('Option 2'),
icon: Icon(Icons.settings),
),
ButtonSegment<String>(
value: 'option3',
label: Text('Option 3'),
icon: Icon(Icons.person),
),
],
selected: {
_selectedOption},
onSelectionChanged: (Set<String> newValues) {
setState(() {
_selectedOption = newValues.first;
if (kDebugMode) {
print('Selected option: $_selectedOption');
}
});
},
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith<Color?>(
(Set<WidgetState> states) {
if (states.contains(WidgetState.selected)) {
return Theme.of(context).colorScheme.primary;
}
return null; // Use the default value.
},
),
),
);
}
在这个更新的示例中:
- 我们在每个
ButtonSegment
中添加了一个icon
属性,分别使用了Icons.home
、Icons.settings
和Icons.person
。 - 这些图标会显示在每个分段按钮的文本旁边。
当您运行这个应用程序时,您将看到每个分段按钮都包含一个相应的图标。用户可以点击这些带有图标的分段按钮进行切换,每次选择时控制台会打印出被选中的值。
PopupMenuButton
好的,让我们来看一个使用 Flutter 的 PopupMenuButton
组件的例子。PopupMenuButton
是一个非常有用的组件,它允许您在用户点击或按下某个小部件时显示一个弹出菜单。
以下是一个示例:
build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('PopupMenuButton Example'),
actions: [
PopupMenuButton(
onSelected: (value) {
// Handle the selected menu item
print('Selected: $value');
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'option1',
child: Text('Option 1'),
),
PopupMenuItem(
value: 'option2',
child: Text('Option 2'),
),
PopupMenuItem(
value: 'option3',
child: Text('Option 3'),
),
],
),
],
),
body: Center(
child: Text('Tap the menu icon to open the popup menu'),
),
);
}
Widget
在这个示例中,我们在 AppBar
的 actions
属性中使用了 PopupMenuButton
。当用户点击 AppBar 上的菜单图标时,会弹出一个包含三个选项的菜单。
PopupMenuButton
有以下主要属性:
onSelected
: 当用户选择一个菜单项时触发的回调函数。在这个示例中,我们简单地打印出被选中的选项。itemBuilder
: 一个构建器函数,它返回一个PopupMenuItem
列表,定义了要显示在弹出菜单中的内容。在这个示例中,我们创建了三个PopupMenuItem
。
除了在 AppBar
中使用,您还可以将 PopupMenuButton
放置在应用程序的任何其他位置。例如,您可以将其放置在按钮或其他交互式小部件旁边。
使用 PopupMenuButton
可以为您的应用程序添加一些有用的功能,如设置、排序或过滤选项。它提供了一种简单且直观的方式来扩展您的用户界面,同时保持清晰和整洁的外观。
ButtonBar
好的,让我们来看一个使用 Flutter 的 ButtonBar
组件的例子。ButtonBar
是一个非常有用的组件,它可以帮助您将多个按钮水平排列在一起,并提供一些常见的布局和样式设置。
以下是一个示例:
class _WidgetPageState extends State<WidgetPage> {
int _count = 0;
void _incrementCount() {
setState(() {
_count++;
});
}
void _decrementCount() {
setState(() {
_count--;
});
}
Widget _mainView() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Count: $_count'),
const SizedBox(height: 16.0),
ButtonBar(
alignment: MainAxisAlignment.center,
buttonPadding: const EdgeInsets.all(5.0),
buttonTextTheme: ButtonTextTheme.primary,
children: [
ElevatedButton(
onPressed: _decrementCount,
child: const Text('-'),
),
ElevatedButton(
onPressed: _incrementCount,
child: const Text('+'),
),
ElevatedButton(
onPressed: _incrementCount,
child: const Text('+'),
),
ElevatedButton(
onPressed: _incrementCount,
child: const Text('+'),
),
ElevatedButton(
onPressed: _incrementCount,
child: const Text('+'),
),
],
),
],
);
}
在这个示例中,我们使用了 ButtonBar
组件来水平排列两个按钮。
ButtonBar
有以下主要属性:
alignment
: 控制按钮在ButtonBar
内的对齐方式。在这个示例中,我们将其设置为MainAxisAlignment.center
。buttonPadding
: 控制每个按钮之间的间距。buttonTextTheme
: 控制按钮文本的主题样式。overflowButtonSpacing
: 当按钮超出屏幕宽度时,控制溢出按钮的间距。
除了这些属性,ButtonBar
还支持一些常见的布局属性,如 mainAxisAlignment
、mainAxisSize
和 crossAxisAlignment
。
在这个示例中,我们在 ButtonBar
内部放置了两个 ElevatedButton
。当点击这些按钮时,它们会分别调用 _incrementCount()
和 _decrementCount()
方法来更新 _count
变量的值。
ExpansionPanel
好的,让我们来看一个使用 Flutter 的 ExpansionPanel
组件的例子。ExpansionPanel
是一个非常有用的组件,它允许您创建一个可以展开和折叠的面板,以显示或隐藏其中的内容。
以下是一个示例:
class _WidgetPageState extends State<WidgetPage> {
final List<bool> _isExpanded = [true, false, false];
Widget _mainView() {
return ListView(
children: [
ExpansionPanelList(
expansionCallback: (int index, bool isExpanded) {
setState(() {
_isExpanded[index] = !isExpanded;
});
},
children: [
ExpansionPanel(
headerBuilder: (BuildContext context, bool isExpanded) {
return const ListTile(
title: Text('Panel 1'),
);
},
body: const Padding(
padding: EdgeInsets.all(16.0),
child: Text('This is the content of Panel 1.'),
),
isExpanded: _isExpanded[0],
),
ExpansionPanel(
headerBuilder: (BuildContext context, bool isExpanded) {
return const ListTile(
title: Text('Panel 2'),
);
},
body: const Padding(
padding: EdgeInsets.all(16.0),
child: Text('This is the content of Panel 2.'),
),
isExpanded: _isExpanded[1],
),
ExpansionPanel(
headerBuilder: (BuildContext context, bool isExpanded) {
return const ListTile(
title: Text('Panel 3'),
);
},
body: const Padding(
padding: EdgeInsets.all(16.0),
child: Text('This is the content of Panel 3.'),
),
isExpanded: _isExpanded[2],
),
],
),
],
);
}
在这个示例中,我们使用了 ExpansionPanelList
和 ExpansionPanel
组件来创建一个可展开和折叠的面板列表。
ExpansionPanelList
有以下主要属性:
expansionCallback
: 当用户点击面板头部时触发的回调函数。在这个示例中,我们使用它来更新_isExpanded
列表中相应面板的状态。children
: 一个ExpansionPanel
列表,定义了要显示在ExpansionPanelList
中的面板。
ExpansionPanel
有以下主要属性:
headerBuilder
: 一个构建器函数,用于定义面板头部的内容。在这个示例中,我们使用了一个ListTile
来显示面板的标题。body
: 一个构建器函数,用于定义面板展开时显示的内容。在这个示例中,我们使用了一个Padding
组件来显示面板的内容。isExpanded
: 控制面板是否处于展开状态。在这个示例中,我们使用_isExpanded
列表来跟踪每个面板的状态。
使用 ExpansionPanel
可以帮助您创建一个可折叠的用户界面,以便于用户浏览和查看内容。它在需要显示大量信息但又想保持界面整洁的情况下非常有用。
OverlayPortal
好的,让我们来看一个使用 Flutter 的 OverlayPortal
组件的例子。OverlayPortal
是一个强大的小部件,它允许您在应用程序的顶层渲染内容,并让它们脱离应用程序的常规 widget 树。这对于创建模态对话框、悬浮按钮、工具提示等 UI 元素非常有用。
以下是一个示例:
final OverlayPortalController _tooltipController = OverlayPortalController();
Widget _mainView() {
return ElevatedButton(
onPressed: _tooltipController.toggle,
child: OverlayPortal(
controller: _tooltipController,
overlayChildBuilder: (BuildContext context) {
return Positioned(
top: 100,
left: 0,
right: 0,
child: Container(
color: Colors.blue,
child: const Text('提示内容 widget'),
),
);
},
child: const Text('点击这里'),
),
);
}
在这个示例中,我们使用 OverlayPortal
组件来渲染一个 ElevatedButton
按钮。
小结
本文探讨了一些不太为人知但却非常强大的Flutter UI组件,如FadeInImage、GridPaper、Hero等,这些隐藏宝藏能为开发者的应用界面带来更精致的视觉效果和更丰富的交互体验。掌握这些高级组件的使用技巧,定能帮助开发者进一步提升Flutter应用的整体UI水平。
感谢阅读本文
如果有什么建议,请在评论中让我知道。我很乐意改进。
flutter 学习路径
- Flutter 优秀插件推荐 https://flutter.ducafecat.com
- Flutter 基础篇1 - Dart 语言学习 https://ducafecat.com/course/dart-learn
- Flutter 基础篇2 - 快速上手 https://ducafecat.com/course/flutter-quickstart-learn
- Flutter 实战1 - Getx Woo 电商APP https://ducafecat.com/course/flutter-woo
- Flutter 实战2 - 上架指南 Apple Store、Google Play https://ducafecat.com/course/flutter-upload-apple-google
- Flutter 基础篇3 - 仿微信朋友圈 https://ducafecat.com/course/flutter-wechat
- Flutter 实战3 - 腾讯即时通讯 第一篇 https://ducafecat.com/course/flutter-tim
- Flutter 实战4 - 腾讯即时通讯 第二篇 https://ducafecat.com/course/flutter-tim-s2
© 猫哥
ducafecat.com
end