티스토리 뷰
Flutter에서 제공하는 Floating Action Button(FAB)은 화면 하단 오른쪽에 떠 있는 원형 버튼으로, 일반적으로 중요한 액션을 대표합니다. 하지만 때로는 하나의 주요 액션 외에도 여러 가지 중요한 액션이 필요할 수 있습니다. 이 경우, 확장 가능한 FAB을 구현하여 사용자가 여러 액션을 수행할 수 있게 하는 것이 좋습니다. 이번 포스트에서는 Flutter로 확장 가능한 FAB을 구현하는 방법을 단계별로 설명하고, 필요한 주요 기능과 코드 샘플을 소개하겠습니다.
확장 가능한 FAB이란?
확장 가능한 FAB은 기본적으로 한 개의 원형 버튼을 제공하며, 이를 클릭하면 여러 개의 액션 버튼이 확장되어 나타납니다. 이 액션 버튼들은 사용자가 선택할 수 있는 다양한 옵션을 나타내며, UI의 직관성과 유연성을 높입니다. 특히, 버튼을 사용하는 작업을 단순화하고, 사용자가 한 번의 클릭으로 여러 작업을 수행할 수 있도록 돕습니다.
ExpandableFab 위젯 정의하기
확장 가능한 FAB을 구현하기 위해 가장 먼저 해야 할 일은 ExpandableFab이라는 새로운 상태 위젯을 만드는 것입니다. 이 위젯은 기본 FAB과 다른 액션 버튼의 확장 및 축소를 제어합니다. 주요 매개변수로는 초기 확장 여부(initialOpen
), 최대 거리(distance
), 액션 버튼 목록(children
)을 설정할 수 있습니다.
@immutable
class ExpandableFab extends StatefulWidget {
const ExpandableFab({
Key? key,
this.initialOpen,
required this.distance,
required this.children,
}) : super(key: key);
final bool? initialOpen;
final double distance;
final List<Widget> children;
@override
_ExpandableFabState createState() => _ExpandableFabState();
}
FAB의 크로스 페이드 애니메이션
ExpandableFab 위젯은 기본적으로 닫힌 상태에서는 "수정" 버튼을, 확장된 상태에서는 "닫기" 버튼을 표시합니다. 이 두 개의 버튼은 확장 및 축소될 때 서로 크로스 페이드(cross-fade) 효과를 통해 전환됩니다.
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 Stack(
alignment: Alignment.bottomRight,
children: [
_buildTapToCloseFab(),
_buildTapToOpenFab(),
],
);
}
Widget _buildTapToCloseFab() {
return SizedBox(
width: 56,
height: 56,
child: Center(
child: Material(
shape: CircleBorder(),
clipBehavior: Clip.antiAlias,
elevation: 4,
child: InkWell(
onTap: _toggle,
child: Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.close, color: Theme.of(context).primaryColor),
),
),
),
),
);
}
Widget _buildTapToOpenFab() {
return IgnorePointer(
ignoring: _open,
child: AnimatedOpacity(
opacity: _open ? 0.0 : 1.0,
duration: Duration(milliseconds: 250),
child: FloatingActionButton(
onPressed: _toggle,
child: Icon(Icons.create),
),
),
);
}
}
ActionButton 위젯 만들기
이제 확장된 FAB에서 사용할 개별 ActionButton 위젯을 만듭니다. 이 위젯은 각 액션 버튼을 나타내며, 둥근 모양과 색상을 설정할 수 있습니다.
@immutable
class ActionButton extends StatelessWidget {
const ActionButton({Key? key, this.onPressed, required this.icon}) : super(key: key);
final VoidCallback? onPressed;
final Widget icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
shape: CircleBorder(),
clipBehavior: Clip.antiAlias,
color: theme.colorScheme.secondary,
elevation: 4,
child: IconButton(
onPressed: onPressed,
icon: icon,
color: theme.colorScheme.onSecondary,
),
);
}
}
확장 및 축소 애니메이션 구현
확장된 FAB은 클릭 시 액션 버튼들이 날아가며 배치되도록 해야 합니다. 이를 위해 액션 버튼의 (x, y)
위치를 제어하는 애니메이션을 설정합니다.
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),
);
}
}
요약 및 팁
Flutter로 확장 가능한 FAB을 구현하면 사용자 인터페이스의 유연성과 직관성을 높일 수 있습니다. ExpandableFab 위젯과 각 액션 버튼을 제어하는 ActionButton 위젯을 구현하는 방법을 이해하고, 다양한 애니메이션 효과를 통해 시각적 매력을 더할 수 있습니다.
확장 및 축소 애니메이션의 크로스 페이드 효과와 각 액션 버튼의 위치 제어를 위해 AnimatedContainer, AnimatedBuilder, Transform 등을 활용하는 것이 핵심입니다. 이를 통해 사용자 경험을 한 단계 더 향상시킬 수 있습니다.
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 Cookbook' 카테고리의 다른 글
Flutter에서 드래그 가능한 UI 요소 구현하기 (0) | 2024.11.02 |
---|---|
Flutter에서 그라디언트 채팅 말풍선 구현하기: 사용자 경험을 향상시키는 방법 (1) | 2024.11.01 |
Flutter에서 타이핑 인디케이터 구현하기: 사용자 인터페이스를 향상시키는 방법 (0) | 2024.10.29 |
Flutter 스태거드 메뉴 애니메이션 구현 가이드: 한 단계씩 따라하는 Flutter 애니메이션 마스터하기 (0) | 2024.10.28 |
Flutter로 쉬머 로딩 효과 구현하기: 상세 가이드 (0) | 2024.10.22 |