拡張可能な FAB を作成する
フローティング アクション ボタン (FAB) は、 コンテンツ領域の右下近くに浮かんでいます。 このボタンは、 対応するコンテンツがありますが、場合によっては主要なアクションがない場合があります。 代わりに、ユーザーが実行できる重要なアクションがいくつかあります。 この場合、次のような拡張可能な FAB を作成できます。 次の図にあります。押すと、この拡張可能な FAB が生成されます 複数のその他のアクション ボタン。各ボタンは次のいずれかに対応します。 批判的な人たち 行動。
次のアニメーションはアプリの動作を示しています。
ExpandableFab ウィジェットを作成する
まず、新しいステートフル ウィジェットを作成します。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
折りたたむと青い編集ボタンが表示されます
展開すると白い閉じるボタンが表示されます。展開したり折りたたんだりすると、
これら 2 つのボタンは、相互にスケールおよびフェードします。
2 つの異なる 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
、
トップボタンとしてクロスフェードの視覚的な外観を可能にします
現れては消えます。
クロスフェード アニメーションを実現するために、開くボタンはAnimatedContainer
スケール変換とAnimatedOpacity
。
開くボタンは縮小され、フェードアウトします。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 の下に戻ります。
この動作には、それぞれの明示的な (x,y) 位置決めが必要です。ActionButton
と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
特定の (x,y) で
周囲の中で調整するStack
。
のAnimatedBuilder
原因となるPositioned
再構築するウィジェット
アニメーションが変わるたびに。のFadeTransition
ウィジェット
それぞれの出現と消滅を調整しますActionButton
それぞれ拡大および縮小します。
最後に、新しいものを使用します_ExpandingActionButton
ウィジェット
以内ExpandableFab
演習を完了します。
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 つのボタンにファンアウトされ、それ自体が に置き換えられます。 閉じるボタン。バツ。
- 閉じるボタンをクリックすると展開された内容が表示されます ボタンは元の FAB に戻り、 のバツは編集アイコンに置き換えられます。
- FAB を再度展開し、任意の FAB をクリックします。 3 つのサテライト ボタンのうちの 1 つを選択すると、ダイアログが表示されます そのボタンのアクションを表します。
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,
),
);
}
}