Жести та обробка торкань у Flutter
Flutter надає потужну систему для обробки жестів та взаємодії користувача з екраном. Від простих натискань до складних мультитач-жестів — все можна реалізувати за допомогою вбудованих віджетів.
GestureDetector
GestureDetector — основний віджет для обробки жестів.
Базові жести
GestureDetector(
// Натискання
onTap: () {
print('Натиснуто!');
},
onTapDown: (TapDownDetails details) {
print('Палець опущено на: ${details.globalPosition}');
},
onTapUp: (TapUpDetails details) {
print('Палець піднято');
},
onTapCancel: () {
print('Натискання скасовано');
},
// Подвійне натискання
onDoubleTap: () {
print('Подвійне натискання!');
},
// Довге натискання
onLongPress: () {
print('Довге натискання!');
},
onLongPressStart: (LongPressStartDetails details) {
print('Довге натискання почалось');
},
onLongPressEnd: (LongPressEndDetails details) {
print('Довге натискання завершилось');
},
child: Container(
width: 200,
height: 200,
color: Colors.blue,
child: Center(child: Text('Торкніться мене')),
),
)Перетягування (Drag)
class DraggableBox extends StatefulWidget {
@override
_DraggableBoxState createState() => _DraggableBoxState();
}
class _DraggableBoxState extends State<DraggableBox> {
Offset _position = Offset(100, 100);
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned(
left: _position.dx,
top: _position.dy,
child: GestureDetector(
onPanStart: (DragStartDetails details) {
print('Перетягування почалось');
},
onPanUpdate: (DragUpdateDetails details) {
setState(() {
_position += details.delta;
});
},
onPanEnd: (DragEndDetails details) {
print('Перетягування завершилось');
print('Швидкість: ${details.velocity}');
},
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(10),
),
child: Center(child: Text('Перетягни')),
),
),
),
],
);
}
}Горизонтальне та вертикальне перетягування
GestureDetector(
// Тільки горизонтальне
onHorizontalDragStart: (details) {
print('Горизонтальне перетягування почалось');
},
onHorizontalDragUpdate: (details) {
print('Зміщення по X: ${details.delta.dx}');
},
onHorizontalDragEnd: (details) {
print('Швидкість по X: ${details.velocity.pixelsPerSecond.dx}');
},
// Тільки вертикальне
onVerticalDragStart: (details) {
print('Вертикальне перетягування почалось');
},
onVerticalDragUpdate: (details) {
print('Зміщення по Y: ${details.delta.dy}');
},
onVerticalDragEnd: (details) {
print('Швидкість по Y: ${details.velocity.pixelsPerSecond.dy}');
},
child: Container(
width: 200,
height: 200,
color: Colors.green,
),
)Масштабування (Scale/Pinch)
class ScalableWidget extends StatefulWidget {
@override
_ScalableWidgetState createState() => _ScalableWidgetState();
}
class _ScalableWidgetState extends State<ScalableWidget> {
double _scale = 1.0;
double _previousScale = 1.0;
double _rotation = 0.0;
double _previousRotation = 0.0;
@override
Widget build(BuildContext context) {
return GestureDetector(
onScaleStart: (ScaleStartDetails details) {
_previousScale = _scale;
_previousRotation = _rotation;
},
onScaleUpdate: (ScaleUpdateDetails details) {
setState(() {
_scale = _previousScale * details.scale;
_rotation = _previousRotation + details.rotation;
});
},
onScaleEnd: (ScaleEndDetails details) {
print('Масштабування завершено');
},
child: Transform.scale(
scale: _scale,
child: Transform.rotate(
angle: _rotation,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
color: Colors.purple,
borderRadius: BorderRadius.circular(20),
),
child: Center(
child: Text(
'Масштаб: ${_scale.toStringAsFixed(2)}',
style: TextStyle(color: Colors.white),
),
),
),
),
),
);
}
}InkWell та InkResponse
Віджети з візуальним ефектом "чорнила" (Material Design).
// InkWell - прямокутний ефект
InkWell(
onTap: () {
print('InkWell натиснуто');
},
onDoubleTap: () {
print('Подвійне натискання');
},
onLongPress: () {
print('Довге натискання');
},
splashColor: Colors.blue.withOpacity(0.5),
highlightColor: Colors.blue.withOpacity(0.3),
borderRadius: BorderRadius.circular(10),
child: Container(
padding: EdgeInsets.all(16),
child: Text('Натисни мене'),
),
)
// InkResponse - круглий ефект
InkResponse(
onTap: () {},
radius: 50,
splashColor: Colors.red,
child: Icon(Icons.favorite, size: 48),
)
// Кастомний splash
Material(
color: Colors.transparent,
child: InkWell(
customBorder: CircleBorder(),
splashFactory: InkRipple.splashFactory,
onTap: () {},
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.blue,
),
child: Icon(Icons.add, color: Colors.white),
),
),
)Draggable та DragTarget
Система drag-and-drop у Flutter.
Базовий Draggable
class DragDropExample extends StatefulWidget {
@override
_DragDropExampleState createState() => _DragDropExampleState();
}
class _DragDropExampleState extends State<DragDropExample> {
String _droppedItem = '';
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Draggable елемент
Draggable<String>(
data: 'Синій квадрат',
// Віджет під час перетягування
feedback: Container(
width: 100,
height: 100,
color: Colors.blue.withOpacity(0.7),
child: Center(
child: Text('Перетягую!', style: TextStyle(color: Colors.white)),
),
),
// Віджет на місці після початку перетягування
childWhenDragging: Container(
width: 100,
height: 100,
color: Colors.grey,
child: Center(child: Text('Було тут')),
),
// Звичайний вигляд
child: Container(
width: 100,
height: 100,
color: Colors.blue,
child: Center(
child: Text('Перетягни', style: TextStyle(color: Colors.white)),
),
),
onDragStarted: () {
print('Перетягування почалось');
},
onDragEnd: (details) {
print('Перетягування завершено: ${details.wasAccepted}');
},
),
// Ціль для drop
DragTarget<String>(
builder: (context, candidateData, rejectedData) {
final isHovering = candidateData.isNotEmpty;
return Container(
width: 200,
height: 200,
decoration: BoxDecoration(
color: isHovering ? Colors.green.shade200 : Colors.green,
border: Border.all(
color: isHovering ? Colors.green.shade800 : Colors.green.shade600,
width: 3,
),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(
_droppedItem.isEmpty ? 'Кинь сюди' : _droppedItem,
style: TextStyle(color: Colors.white, fontSize: 18),
),
),
);
},
onWillAccept: (data) {
// Чи приймати цей елемент?
return data != null;
},
onAccept: (data) {
setState(() {
_droppedItem = data;
});
},
onLeave: (data) {
print('Елемент покинув зону');
},
),
],
);
}
}LongPressDraggable
LongPressDraggable<String>(
data: 'Довге натискання',
delay: Duration(milliseconds: 500),
feedback: Material(
elevation: 4,
child: Container(
width: 100,
height: 100,
color: Colors.orange,
),
),
child: Container(
width: 100,
height: 100,
color: Colors.orange,
child: Center(child: Text('Довго натисни')),
),
)Dismissible
Віджет для свайпу елементів (видалення, архівування тощо).
class DismissibleListExample extends StatefulWidget {
@override
_DismissibleListExampleState createState() => _DismissibleListExampleState();
}
class _DismissibleListExampleState extends State<DismissibleListExample> {
List<String> items = List.generate(10, (i) => 'Елемент ${i + 1}');
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Dismissible(
key: Key(item),
// Напрямок свайпу
direction: DismissDirection.horizontal,
// Фон при свайпі вправо (видалення)
background: Container(
color: Colors.red,
alignment: Alignment.centerLeft,
padding: EdgeInsets.only(left: 20),
child: Icon(Icons.delete, color: Colors.white),
),
// Фон при свайпі вліво (архів)
secondaryBackground: Container(
color: Colors.green,
alignment: Alignment.centerRight,
padding: EdgeInsets.only(right: 20),
child: Icon(Icons.archive, color: Colors.white),
),
// Підтвердження видалення
confirmDismiss: (direction) async {
if (direction == DismissDirection.startToEnd) {
// Свайп вправо - видалення
return await showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Видалити?'),
content: Text('Ви впевнені, що хочете видалити "$item"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text('Скасувати'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Видалити'),
),
],
),
);
} else {
// Свайп вліво - архів (без підтвердження)
return true;
}
},
onDismissed: (direction) {
setState(() {
items.removeAt(index);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
direction == DismissDirection.startToEnd
? '$item видалено'
: '$item заархівовано',
),
action: SnackBarAction(
label: 'Відмінити',
onPressed: () {
setState(() {
items.insert(index, item);
});
},
),
),
);
},
child: ListTile(
title: Text(item),
leading: Icon(Icons.drag_handle),
),
);
},
);
}
}ReorderableListView
Список з можливістю зміни порядку елементів.
class ReorderableExample extends StatefulWidget {
@override
_ReorderableExampleState createState() => _ReorderableExampleState();
}
class _ReorderableExampleState extends State<ReorderableExample> {
List<String> items = List.generate(10, (i) => 'Елемент ${i + 1}');
@override
Widget build(BuildContext context) {
return ReorderableListView.builder(
itemCount: items.length,
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final item = items.removeAt(oldIndex);
items.insert(newIndex, item);
});
},
itemBuilder: (context, index) {
return ListTile(
key: Key(items[index]),
title: Text(items[index]),
leading: Icon(Icons.drag_handle),
trailing: ReorderableDragStartListener(
index: index,
child: Icon(Icons.drag_indicator),
),
);
},
proxyDecorator: (child, index, animation) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
final elevation = lerpDouble(0, 6, animation.value)!;
return Material(
elevation: elevation,
shadowColor: Colors.black45,
child: child,
);
},
child: child,
);
},
);
}
}Listener
Низькорівневий віджет для обробки pointer events.
Listener(
onPointerDown: (PointerDownEvent event) {
print('Pointer down: ${event.position}');
print('Buttons: ${event.buttons}');
print('Pressure: ${event.pressure}');
},
onPointerMove: (PointerMoveEvent event) {
print('Pointer move: ${event.delta}');
},
onPointerUp: (PointerUpEvent event) {
print('Pointer up');
},
onPointerCancel: (PointerCancelEvent event) {
print('Pointer cancel');
},
onPointerHover: (PointerHoverEvent event) {
// Для миші
print('Hover: ${event.position}');
},
onPointerSignal: (PointerSignalEvent event) {
if (event is PointerScrollEvent) {
print('Scroll: ${event.scrollDelta}');
}
},
child: Container(
width: 300,
height: 300,
color: Colors.amber,
),
)RawGestureDetector
Для створення власних розпізнавачів жестів.
class CustomGestureExample extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RawGestureDetector(
gestures: {
// Власний розпізнавач для потрійного натискання
SerialTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<SerialTapGestureRecognizer>(
() => SerialTapGestureRecognizer(),
(SerialTapGestureRecognizer instance) {
instance.onSerialTapDown = (SerialTapDownDetails details) {
if (details.count == 3) {
print('Потрійне натискання!');
}
};
},
),
},
child: Container(
width: 200,
height: 200,
color: Colors.teal,
child: Center(child: Text('Потрійний тап')),
),
);
}
}InteractiveViewer
Віджет для масштабування та панорамування контенту.
InteractiveViewer(
boundaryMargin: EdgeInsets.all(100),
minScale: 0.5,
maxScale: 4.0,
scaleEnabled: true,
panEnabled: true,
constrained: false,
onInteractionStart: (details) {
print('Interaction started');
},
onInteractionUpdate: (details) {
print('Scale: ${details.scale}');
},
onInteractionEnd: (details) {
print('Interaction ended');
},
child: Image.network(
'https://via.placeholder.com/1000',
fit: BoxFit.contain,
),
)
// З трансформаційним контролером
class ControlledViewer extends StatefulWidget {
@override
_ControlledViewerState createState() => _ControlledViewerState();
}
class _ControlledViewerState extends State<ControlledViewer> {
final TransformationController _controller = TransformationController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _resetZoom() {
_controller.value = Matrix4.identity();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
ElevatedButton(
onPressed: _resetZoom,
child: Text('Скинути масштаб'),
),
Expanded(
child: InteractiveViewer(
transformationController: _controller,
child: Image.network('https://via.placeholder.com/1000'),
),
),
],
);
}
}Обробка жестів на рівні застосунку
// Виявлення свайпу назад (Android)
WillPopScope(
onWillPop: () async {
// Показати діалог підтвердження
final shouldPop = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Вийти?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text('Ні'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Так'),
),
],
),
);
return shouldPop ?? false;
},
child: Scaffold(
body: Center(child: Text('Натисніть назад')),
),
)
// Глобальна обробка жестів
class GestureWrapperApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
// Закрити клавіатуру при натисканні поза полем
FocusScope.of(context).unfocus();
},
child: MaterialApp(
home: HomeScreen(),
),
);
}
}Найкращі практики
Використовуйте InkWell для Material Design ефектів замість GestureDetector.
Уникайте конфліктів жестів — не розміщуйте кілька GestureDetector з однаковими жестами.
Надавайте візуальний зворотний зв'язок — користувач повинен бачити реакцію на свої дії.
Використовуйте HitTestBehavior для контролю області торкання.
Тестуйте на реальних пристроях — емулятори не передають усіх нюансів жестів.
Висновок
Flutter надає комплексну систему для обробки жестів — від простих натискань до складних мультитач-взаємодій. Правильне використання цих інструментів дозволяє створювати інтуїтивні та приємні у використанні інтерфейси.