建立可展開的 FAB
浮動動作按鈕 (FAB) 是一個圓形的按鈕,漂浮在內容區域的右下角附近。此按鈕代表相應內容的主要動作,但有時沒有主要動作。相反地,使用者可能需要執行幾個重要的動作。在這種情況下,您可以建立一個可展開的 FAB,如圖所示。當按下時,這個可展開的 FAB 會產生多個其他的動作按鈕。每個按鈕對應其中一個重要的動作。
以下動畫顯示了應用程式的行為
建立一個可展開的 FAB 小工具
#首先,建立一個新的有狀態小工具,名為 ExpandableFab
。這個小工具會顯示主要的 FAB,並協調其他動作按鈕的展開和收合。此小工具會接收一些參數,用來判斷 ExpandedFab
是否在一開始就處於展開位置、每個動作按鈕的最大距離以及子項清單。您稍後將使用此清單來提供其他動作按鈕。
@immutable
class ExpandableFab extends StatefulWidget {
const ExpandableFab({
super.key,
this.initialOpen,
required this.distance,
required this.children,
});
final bool? initialOpen;
final double distance;
final List<Widget> children;
@override
State<ExpandableFab> createState() => _ExpandableFabState();
}
class _ExpandableFabState extends State<ExpandableFab> {
@override
Widget build(BuildContext context) {
return const SizedBox();
}
}
FAB 淡入淡出效果
#ExpandableFab
在收合時會顯示藍色的編輯按鈕,展開時會顯示白色的關閉按鈕。在展開和收合時,這兩個按鈕會互相縮放和淡入淡出。
實作兩個不同 FAB 之間的展開和收合淡入淡出效果。
class _ExpandableFabState extends State<ExpandableFab> {
bool _open = false;
@override
void initState() {
super.initState();
_open = widget.initialOpen ?? false;
}
void _toggle() {
setState(() {
_open = !_open;
});
}
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: Stack(
alignment: Alignment.bottomRight,
clipBehavior: Clip.none,
children: [
_buildTapToCloseFab(),
_buildTapToOpenFab(),
],
),
);
}
Widget _buildTapToCloseFab() {
return SizedBox(
width: 56,
height: 56,
child: Center(
child: Material(
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
elevation: 4,
child: InkWell(
onTap: _toggle,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(
Icons.close,
color: Theme.of(context).primaryColor,
),
),
),
),
),
);
}
Widget _buildTapToOpenFab() {
return IgnorePointer(
ignoring: _open,
child: AnimatedContainer(
transformAlignment: Alignment.center,
transform: Matrix4.diagonal3Values(
_open ? 0.7 : 1.0,
_open ? 0.7 : 1.0,
1.0,
),
duration: const Duration(milliseconds: 250),
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
child: AnimatedOpacity(
opacity: _open ? 0.0 : 1.0,
curve: const Interval(0.25, 1.0, curve: Curves.easeInOut),
duration: const Duration(milliseconds: 250),
child: FloatingActionButton(
onPressed: _toggle,
child: const Icon(Icons.create),
),
),
),
);
}
}
開啟按鈕位於 Stack
內的關閉按鈕之上,使得頂部的按鈕在出現和消失時產生視覺上的淡入淡出效果。
為了實現淡入淡出動畫,開啟按鈕使用具有縮放轉換和 AnimatedOpacity
的 AnimatedContainer
。當 ExpandableFab
從收合狀態變為展開狀態時,開啟按鈕會縮小並淡出。然後,當 ExpandableFab
從展開狀態變為收合狀態時,開啟按鈕會放大並淡入。
您會注意到開啟按鈕是用 IgnorePointer
小工具包裝的。這是因為開啟按鈕總是存在,即使它是透明的。如果沒有 IgnorePointer
,開啟按鈕總是會接收點擊事件,即使關閉按鈕是可見的。
建立一個 ActionButton 小工具
#從 ExpandableFab
展開的每個按鈕都具有相同的設計。它們是帶有白色圖示的藍色圓圈。更精確地說,按鈕背景顏色是 ColorScheme.secondary
顏色,圖示顏色是 ColorScheme.onSecondary
。
定義一個新的無狀態小工具,名為 ActionButton
,以顯示這些圓形按鈕。
@immutable
class ActionButton extends StatelessWidget {
const ActionButton({
super.key,
this.onPressed,
required this.icon,
});
final VoidCallback? onPressed;
final Widget icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
color: theme.colorScheme.secondary,
elevation: 4,
child: IconButton(
onPressed: onPressed,
icon: icon,
color: theme.colorScheme.onSecondary,
),
);
}
}
將這個新的 ActionButton
小工具的幾個實例傳遞到您的 ExpandableFab
中。
floatingActionButton: ExpandableFab(
distance: 112,
children: [
ActionButton(
onPressed: () => _showAction(context, 0),
icon: const Icon(Icons.format_size),
),
ActionButton(
onPressed: () => _showAction(context, 1),
icon: const Icon(Icons.insert_photo),
),
ActionButton(
onPressed: () => _showAction(context, 2),
icon: const Icon(Icons.videocam),
),
],
),
展開和收合動作按鈕
#子 ActionButton
在展開時應該從開啟 FAB 下方飛出。然後,子 ActionButton
在收合時應該飛回開啟 FAB 下方。此動作需要每個 ActionButton
的明確 (x, y) 定位,以及一個 Animation
來編排這些 (x, y) 位置隨時間的變化。
引入一個 AnimationController
和一個 Animation
,以控制各種 ActionButton
展開和收合的速度。
class _ExpandableFabState extends State<ExpandableFab>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _expandAnimation;
bool _open = false;
@override
void initState() {
super.initState();
_open = widget.initialOpen ?? false;
_controller = AnimationController(
value: _open ? 1.0 : 0.0,
duration: const Duration(milliseconds: 250),
vsync: this,
);
_expandAnimation = CurvedAnimation(
curve: Curves.fastOutSlowIn,
reverseCurve: Curves.easeOutQuad,
parent: _controller,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggle() {
setState(() {
_open = !_open;
if (_open) {
_controller.forward();
} else {
_controller.reverse();
}
});
}
}
接下來,引入一個新的無狀態小工具,名為 _ExpandingActionButton
,並設定此小工具來動畫和定位單個 ActionButton
。ActionButton
會以通用 Widget
的形式提供,稱為 child
。
@immutable
class _ExpandingActionButton extends StatelessWidget {
const _ExpandingActionButton({
required this.directionInDegrees,
required this.maxDistance,
required this.progress,
required this.child,
});
final double directionInDegrees;
final double maxDistance;
final Animation<double> progress;
final Widget child;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: progress,
builder: (context, child) {
final offset = Offset.fromDirection(
directionInDegrees * (math.pi / 180.0),
progress.value * maxDistance,
);
return Positioned(
right: 4.0 + offset.dx,
bottom: 4.0 + offset.dy,
child: Transform.rotate(
angle: (1.0 - progress.value) * math.pi / 2,
child: child!,
),
);
},
child: FadeTransition(
opacity: progress,
child: child,
),
);
}
}
_ExpandingActionButton
中最重要的部分是 Positioned
小工具,它將 child
定位在周圍 Stack
內的特定 (x, y) 座標。每當動畫發生變化時,AnimatedBuilder
都會導致 Positioned
小工具重新建構。FadeTransition
小工具則協調每個 ActionButton
在展開和收合時的出現和消失。
最後,在 ExpandableFab
中使用新的 _ExpandingActionButton
小工具來完成練習。
class _ExpandableFabState extends State<ExpandableFab>
with SingleTickerProviderStateMixin {
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: Stack(
alignment: Alignment.bottomRight,
clipBehavior: Clip.none,
children: [
_buildTapToCloseFab(),
..._buildExpandingActionButtons(),
_buildTapToOpenFab(),
],
),
);
}
List<Widget> _buildExpandingActionButtons() {
final children = <Widget>[];
final count = widget.children.length;
final step = 90.0 / (count - 1);
for (var i = 0, angleInDegrees = 0.0;
i < count;
i++, angleInDegrees += step) {
children.add(
_ExpandingActionButton(
directionInDegrees: angleInDegrees,
maxDistance: widget.distance,
progress: _expandAnimation,
child: widget.children[i],
),
);
}
return children;
}
}
恭喜!您現在有一個可展開的 FAB 了。
互動範例
#執行應用程式
- 點擊右下角的 FAB,它會以編輯圖示表示。它會展開成 3 個按鈕,並且本身會被關閉按鈕取代,該按鈕以 X 表示。
- 點擊關閉按鈕,以查看展開的按鈕飛回原始 FAB,並且 X 會被編輯圖示取代。
- 再次展開 FAB,然後點擊 3 個衛星按鈕中的任何一個,以查看代表該按鈕動作的對話方塊。
import 'dart:math' as math;
import 'package:flutter/material.dart';
void main() {
runApp(
const MaterialApp(
home: ExampleExpandableFab(),
debugShowCheckedModeBanner: false,
),
);
}
@immutable
class ExampleExpandableFab extends StatelessWidget {
static const _actionTitles = ['Create Post', 'Upload Photo', 'Upload Video'];
const ExampleExpandableFab({super.key});
void _showAction(BuildContext context, int index) {
showDialog<void>(
context: context,
builder: (context) {
return AlertDialog(
content: Text(_actionTitles[index]),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('CLOSE'),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Expandable Fab'),
),
body: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: 25,
itemBuilder: (context, index) {
return FakeItem(isBig: index.isOdd);
},
),
floatingActionButton: ExpandableFab(
distance: 112,
children: [
ActionButton(
onPressed: () => _showAction(context, 0),
icon: const Icon(Icons.format_size),
),
ActionButton(
onPressed: () => _showAction(context, 1),
icon: const Icon(Icons.insert_photo),
),
ActionButton(
onPressed: () => _showAction(context, 2),
icon: const Icon(Icons.videocam),
),
],
),
);
}
}
@immutable
class ExpandableFab extends StatefulWidget {
const ExpandableFab({
super.key,
this.initialOpen,
required this.distance,
required this.children,
});
final bool? initialOpen;
final double distance;
final List<Widget> children;
@override
State<ExpandableFab> createState() => _ExpandableFabState();
}
class _ExpandableFabState extends State<ExpandableFab>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _expandAnimation;
bool _open = false;
@override
void initState() {
super.initState();
_open = widget.initialOpen ?? false;
_controller = AnimationController(
value: _open ? 1.0 : 0.0,
duration: const Duration(milliseconds: 250),
vsync: this,
);
_expandAnimation = CurvedAnimation(
curve: Curves.fastOutSlowIn,
reverseCurve: Curves.easeOutQuad,
parent: _controller,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggle() {
setState(() {
_open = !_open;
if (_open) {
_controller.forward();
} else {
_controller.reverse();
}
});
}
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: Stack(
alignment: Alignment.bottomRight,
clipBehavior: Clip.none,
children: [
_buildTapToCloseFab(),
..._buildExpandingActionButtons(),
_buildTapToOpenFab(),
],
),
);
}
Widget _buildTapToCloseFab() {
return SizedBox(
width: 56,
height: 56,
child: Center(
child: Material(
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
elevation: 4,
child: InkWell(
onTap: _toggle,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(
Icons.close,
color: Theme.of(context).primaryColor,
),
),
),
),
),
);
}
List<Widget> _buildExpandingActionButtons() {
final children = <Widget>[];
final count = widget.children.length;
final step = 90.0 / (count - 1);
for (var i = 0, angleInDegrees = 0.0;
i < count;
i++, angleInDegrees += step) {
children.add(
_ExpandingActionButton(
directionInDegrees: angleInDegrees,
maxDistance: widget.distance,
progress: _expandAnimation,
child: widget.children[i],
),
);
}
return children;
}
Widget _buildTapToOpenFab() {
return IgnorePointer(
ignoring: _open,
child: AnimatedContainer(
transformAlignment: Alignment.center,
transform: Matrix4.diagonal3Values(
_open ? 0.7 : 1.0,
_open ? 0.7 : 1.0,
1.0,
),
duration: const Duration(milliseconds: 250),
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
child: AnimatedOpacity(
opacity: _open ? 0.0 : 1.0,
curve: const Interval(0.25, 1.0, curve: Curves.easeInOut),
duration: const Duration(milliseconds: 250),
child: FloatingActionButton(
onPressed: _toggle,
child: const Icon(Icons.create),
),
),
),
);
}
}
@immutable
class _ExpandingActionButton extends StatelessWidget {
const _ExpandingActionButton({
required this.directionInDegrees,
required this.maxDistance,
required this.progress,
required this.child,
});
final double directionInDegrees;
final double maxDistance;
final Animation<double> progress;
final Widget child;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: progress,
builder: (context, child) {
final offset = Offset.fromDirection(
directionInDegrees * (math.pi / 180.0),
progress.value * maxDistance,
);
return Positioned(
right: 4.0 + offset.dx,
bottom: 4.0 + offset.dy,
child: Transform.rotate(
angle: (1.0 - progress.value) * math.pi / 2,
child: child!,
),
);
},
child: FadeTransition(
opacity: progress,
child: child,
),
);
}
}
@immutable
class ActionButton extends StatelessWidget {
const ActionButton({
super.key,
this.onPressed,
required this.icon,
});
final VoidCallback? onPressed;
final Widget icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
color: theme.colorScheme.secondary,
elevation: 4,
child: IconButton(
onPressed: onPressed,
icon: icon,
color: theme.colorScheme.onSecondary,
),
);
}
}
@immutable
class FakeItem extends StatelessWidget {
const FakeItem({
super.key,
required this.isBig,
});
final bool isBig;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 24),
height: isBig ? 128 : 36,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
color: Colors.grey.shade300,
),
);
}
}
除非另有說明,否則本網站上的文件反映了 Flutter 的最新穩定版本。頁面最後更新於 2024-06-26。 檢視原始碼 或 回報問題。