拖动 UI 元素
拖放是常见的移动应用交互方式。当用户长按(有时称为_触摸并按住_)一个小部件时,另一个小部件会出现在用户手指下方,用户将小部件拖动到最终位置并释放。在这个示例中,您将构建一个拖放交互,用户长按选择的食品,然后将该食品拖动到正在付款的顾客的图片上。
下面的动画显示了应用程序的行为:
这个示例从预先构建的菜单项列表和顾客行开始。第一步是识别长按并显示菜单项的可拖动照片。
按压和拖动
#Flutter 提供了一个名为 LongPressDraggable
的小部件,它提供了开始拖放交互所需的确切行为。LongPressDraggable
小部件识别何时发生长按,然后在用户手指附近显示一个新的小部件。当用户拖动时,小部件会跟随用户的手指移动。LongPressDraggable
使您可以完全控制用户拖动的小部件。
每个菜单列表项都使用自定义 MenuListItem
小部件显示。
MenuListItem(
name: item.name,
price: item.formattedTotalItemPrice,
photoProvider: item.imageProvider,
)
使用 LongPressDraggable
小部件包装 MenuListItem
小部件。
LongPressDraggable<Item>(
data: item,
dragAnchorStrategy: pointerDragAnchorStrategy,
feedback: DraggingListItem(
dragKey: _draggableKey,
photoProvider: item.imageProvider,
),
child: MenuListItem(
name: item.name,
price: item.formattedTotalItemPrice,
photoProvider: item.imageProvider,
),
);
在这种情况下,当用户长按 MenuListItem
小部件时,LongPressDraggable
小部件会显示一个 DraggingListItem
。这个 DraggingListItem
显示所选食物项的照片,位于用户手指下方居中。
dragAnchorStrategy
属性设置为 pointerDragAnchorStrategy
。此属性值指示 LongPressDraggable
基于用户的手指位置来确定 DraggableListItem
的位置。当用户移动手指时,DraggableListItem
会随之移动。
如果在项目放下时没有传输任何信息,则拖放几乎没有用处。为此,LongPressDraggable
获取一个 data
参数。在这种情况下,data
的类型为 Item
,它包含用户按下食物菜单项的信息。
与 LongPressDraggable
关联的 data
会发送到一个名为 DragTarget
的特殊小部件,用户在该小部件上释放拖动手势。您接下来将实现放下行为。
放下可拖动项
#用户可以随意放下 LongPressDraggable
,但是除非将其放在 DragTarget
的顶部,否则放下可拖动项不会产生任何效果。当用户将可拖动项放在 DragTarget
小部件的顶部时,DragTarget
小部件可以接受或拒绝来自可拖动项的数据。
在这个示例中,用户应该将菜单项放在 CustomerCart
小部件上才能将菜单项添加到用户的购物车中。
CustomerCart(
hasItems: customer.items.isNotEmpty,
highlighted: candidateItems.isNotEmpty,
customer: customer,
);
使用 DragTarget
小部件包装 CustomerCart
小部件。
DragTarget<Item>(
builder: (context, candidateItems, rejectedItems) {
return CustomerCart(
hasItems: customer.items.isNotEmpty,
highlighted: candidateItems.isNotEmpty,
customer: customer,
);
},
onAcceptWithDetails: (details) {
_itemDroppedOnCustomerCart(
item: details.data,
customer: customer,
);
},
)
DragTarget
显示您现有的部件,并与 LongPressDraggable
协调以识别用户何时将可拖动项拖到 DragTarget
的顶部。DragTarget
还识别用户何时将可拖动项放在 DragTarget
小部件的顶部。
当用户将可拖动项拖到 DragTarget
小部件上时,candidateItems
包含用户正在拖动的项目数据。此可拖动项允许您更改用户拖动在其上时小部件的外观。在这种情况下,每当任何项目拖到 DragTarget
小部件上方时,Customer
小部件都会变为红色。红色的视觉外观是使用 CustomerCart
小部件中的 highlighted
属性配置的。
当用户将可拖动项放在 DragTarget
小部件上时,将调用 onAcceptWithDetails
回调。这时您可以决定是否接受已放下数据。在本例中,该项目始终被接受并被处理。您可能选择检查传入的项目以做出不同的决定。
请注意,放在 DragTarget
上的项目类型必须与从 LongPressDraggable
拖动的项目类型匹配。如果类型不兼容,则不会调用 onAcceptWithDetails
方法。
通过配置 DragTarget
小部件以接受所需的数据,您现在可以通过拖放将数据从 UI 的一部分传输到另一部分。
在下一步中,您将使用已放下的菜单项更新顾客的购物车。
将菜单项添加到购物车
#每个顾客都由一个 Customer
对象表示,该对象维护一个项目购物车和总价格。
class Customer {
Customer({
required this.name,
required this.imageProvider,
List<Item>? items,
}) : items = items ?? [];
final String name;
final ImageProvider imageProvider;
final List<Item> items;
String get formattedTotalItemPrice {
final totalPriceCents =
items.fold<int>(0, (prev, item) => prev + item.totalPriceCents);
return '\$${(totalPriceCents / 100.0).toStringAsFixed(2)}';
}
}
CustomerCart
小部件根据 Customer
实例显示顾客的照片、姓名、总计和项目数量。
要在放下菜单项时更新顾客的购物车,请将已放下的项目添加到关联的 Customer
对象中。
void _itemDroppedOnCustomerCart({
required Item item,
required Customer customer,
}) {
setState(() {
customer.items.add(item);
});
}
当用户将菜单项放在 CustomerCart
小部件上时,将在 onAcceptWithDetails()
中调用 _itemDroppedOnCustomerCart
方法。通过将已放下的项目添加到 customer
对象中,并调用 setState()
以导致布局更新,UI 将使用新的顾客总价格和项目数量刷新。
恭喜!您现在拥有一个拖放交互,可以将食物项添加到顾客的购物车中。
交互式示例
#运行应用程序:
- 滚动浏览食物项目。
- 用手指长按一个项目,或用鼠标单击并按住。
- 按住时,食物项目的图片将出现在列表上方。
- 拖动图片并将其放在屏幕底部的其中一个人身上。图片下方的文字将更新以反映该人的费用。您可以继续添加食物项目并观察费用的累积。
import 'package:flutter/material.dart';
void main() {
runApp(
const MaterialApp(
home: ExampleDragAndDrop(),
debugShowCheckedModeBanner: false,
),
);
}
const List<Item> _items = [
Item(
name: '菠菜披萨',
totalPriceCents: 1299,
uid: '1',
imageProvider: NetworkImage('https://docs.flutter.dev'
'/cookbook/img-files/effects/split-check/Food1.jpg'),
),
Item(
name: '蔬菜盛宴',
totalPriceCents: 799,
uid: '2',
imageProvider: NetworkImage('https://docs.flutter.dev'
'/cookbook/img-files/effects/split-check/Food2.jpg'),
),
Item(
name: '鸡肉帕尔马干酪',
totalPriceCents: 1499,
uid: '3',
imageProvider: NetworkImage('https://docs.flutter.dev'
'/cookbook/img-files/effects/split-check/Food3.jpg'),
),
];
@immutable
class ExampleDragAndDrop extends StatefulWidget {
const ExampleDragAndDrop({super.key});
@override
State<ExampleDragAndDrop> createState() => _ExampleDragAndDropState();
}
class _ExampleDragAndDropState extends State<ExampleDragAndDrop>
with TickerProviderStateMixin {
final List<Customer> _people = [
Customer(
name: 'Makayla',
imageProvider: const NetworkImage('https://docs.flutter.dev'
'/cookbook/img-files/effects/split-check/Avatar1.jpg'),
),
Customer(
name: 'Nathan',
imageProvider: const NetworkImage('https://docs.flutter.dev'
'/cookbook/img-files/effects/split-check/Avatar2.jpg'),
),
Customer(
name: 'Emilio',
imageProvider: const NetworkImage('https://docs.flutter.dev'
'/cookbook/img-files/effects/split-check/Avatar3.jpg'),
),
];
final GlobalKey _draggableKey = GlobalKey();
void _itemDroppedOnCustomerCart({
required Item item,
required Customer customer,
}) {
setState(() {
customer.items.add(item);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF7F7F7),
appBar: _buildAppBar(),
body: _buildContent(),
);
}
PreferredSizeWidget _buildAppBar() {
return AppBar(
iconTheme: const IconThemeData(color: Color(0xFFF64209)),
title: Text(
'点餐',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontSize: 36,
color: const Color(0xFFF64209),
fontWeight: FontWeight.bold,
),
),
backgroundColor: const Color(0xFFF7F7F7),
elevation: 0,
);
}
Widget _buildContent() {
return Stack(
children: [
SafeArea(
child: Column(
children: [
Expanded(
child: _buildMenuList(),
),
_buildPeopleRow(),
],
),
),
],
);
}
Widget _buildMenuList() {
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _items.length,
separatorBuilder: (context, index) {
return const SizedBox(
height: 12,
);
},
itemBuilder: (context, index) {
final item = _items[index];
return _buildMenuItem(
item: item,
);
},
);
}
Widget _buildMenuItem({
required Item item,
}) {
return LongPressDraggable<Item>(
data: item,
dragAnchorStrategy: pointerDragAnchorStrategy,
feedback: DraggingListItem(
dragKey: _draggableKey,
photoProvider: item.imageProvider,
),
child: MenuListItem(
name: item.name,
price: item.formattedTotalItemPrice,
photoProvider: item.imageProvider,
),
);
}
Widget _buildPeopleRow() {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 20,
),
child: Row(
children: _people.map(_buildPersonWithDropZone).toList(),
),
);
}
Widget _buildPersonWithDropZone(Customer customer) {
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
),
child: DragTarget<Item>(
builder: (context, candidateItems, rejectedItems) {
return CustomerCart(
hasItems: customer.items.isNotEmpty,
highlighted: candidateItems.isNotEmpty,
customer: customer,
);
},
onAcceptWithDetails: (details) {
_itemDroppedOnCustomerCart(
item: details.data,
customer: customer,
);
},
),
),
);
}
}
class CustomerCart extends StatelessWidget {
const CustomerCart({
super.key,
required this.customer,
this.highlighted = false,
this.hasItems = false,
});
final Customer customer;
final bool highlighted;
final bool hasItems;
@override
Widget build(BuildContext context) {
final textColor = highlighted ? Colors.white : Colors.black;
return Transform.scale(
scale: highlighted ? 1.075 : 1.0,
child: Material(
elevation: highlighted ? 8 : 4,
borderRadius: BorderRadius.circular(22),
color: highlighted ? const Color(0xFFF64209) : Colors.white,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ClipOval(
child: SizedBox(
width: 46,
height: 46,
child: Image(
image: customer.imageProvider,
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 8),
Text(
customer.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: textColor,
fontWeight:
hasItems ? FontWeight.normal : FontWeight.bold,
),
),
Visibility(
visible: hasItems,
maintainState: true,
maintainAnimation: true,
maintainSize: true,
child: Column(
children: [
const SizedBox(height: 4),
Text(
customer.formattedTotalItemPrice,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${customer.items.length} item${customer.items.length != 1 ? 's' : ''}',
style: Theme.of(context).textTheme.titleMedium!.copyWith(
color: textColor,
fontSize: 12,
),
),
],
),
)
],
),
),
),
);
}
}
class MenuListItem extends StatelessWidget {
const MenuListItem({
super.key,
this.name = '',
this.price = '',
required this.photoProvider,
this.isDepressed = false,
});
final String name;
final String price;
final ImageProvider photoProvider;
final bool isDepressed;
@override
Widget build(BuildContext context) {
return Material(
elevation: 12,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 120,
height: 120,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
curve: Curves.easeInOut,
height: isDepressed ? 115 : 120,
width: isDepressed ? 115 : 120,
child: Image(
image: photoProvider,
fit: BoxFit.cover,
),
),
),
),
),
const SizedBox(width: 30),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontSize: 18,
),
),
const SizedBox(height: 10),
Text(
price,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
],
),
),
],
),
),
);
}
}
class DraggingListItem extends StatelessWidget {
const DraggingListItem({
super.key,
required this.dragKey,
required this.photoProvider,
});
final GlobalKey dragKey;
final ImageProvider photoProvider;
@override
Widget build(BuildContext context) {
return FractionalTranslation(
translation: const Offset(-0.5, -0.5),
child: ClipRRect(
key: dragKey,
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 150,
width: 150,
child: Opacity(
opacity: 0.85,
child: Image(
image: photoProvider,
fit: BoxFit.cover,
),
),
),
),
);
}
}
@immutable
class Item {
const Item({
required this.totalPriceCents,
required this.name,
required this.uid,
required this.imageProvider,
});
final int totalPriceCents;
final String name;
final String uid;
final ImageProvider imageProvider;
String get formattedTotalItemPrice =>
'\$${(totalPriceCents / 100.0).toStringAsFixed(2)}';
}
class Customer {
Customer({
required this.name,
required this.imageProvider,
List<Item>? items,
}) : items = items ?? [];
final String name;
final ImageProvider imageProvider;
final List<Item> items;
String get formattedTotalItemPrice {
final totalPriceCents =
items.fold<int>(0, (prev, item) => prev + item.totalPriceCents);
return '\$${(totalPriceCents / 100.0).toStringAsFixed(2)}';
}
}
除非另有说明,否则本网站上的文档反映的是 Flutter 的最新稳定版本。页面最后更新于 2025-01-30。 查看源代码 或 报告问题。