创建一个可拖放的 UI 组件
拖放是移动应用程序中的一种常见交互。当用户长按(有时称为“触摸并按住”)某个 widget 时,另一个 widget 会出现在用户的手指下方,用户可以将该 widget 拖动到最终位置并释放。在这个示例中,你将构建一个拖放交互,用户可以长按一个食物选项,然后将该食物拖动到正在为其付款的顾客图片上。
下面的动画展示了应用程序的行为:
本示例从一个预先构建的菜单项列表和一排顾客开始。第一步是识别长按操作并显示一个可拖放的菜单项图片。
按下并拖动
#Flutter 提供了一个名为 LongPressDraggable
的 widget,它提供了开始拖放交互所需的确切行为。
LongPressDraggable
widget 能够识别长按操作,然后在用户手指附近显示一个新 widget。当用户拖动时,该 widget 会跟随用户的手指移动。
LongPressDraggable
让你完全控制用户拖动的 widget。
每个菜单列表项都通过一个自定义的 MenuListItem
widget 来显示。
MenuListItem(
name: item.name,
price: item.formattedTotalItemPrice,
photoProvider: item.imageProvider,
)
用 LongPressDraggable
widget 包裹 MenuLIstItem
widget。
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
widget 时,
LongPressDraggable
widget 会显示一个 DraggingListItem
。这个 DraggingListItem
会在用户手指下方居中显示所选的食物图片。
dragAnchorStrategy
属性设置为 pointerDragAnchorStrategy
。这个属性的值指示 LongPressDraggable
将 DraggableListItem
的位置基于用户的手指来定位。当用户移动手指时,DraggableListItem
也会跟随移动。
如果拖放操作在释放时没有传递任何信息,那么它几乎没有什么用处。为此,LongPressDraggable
提供了一个 data
参数。在本例中,data
的类型是 Item
,它包含了用户按下的菜单项的食物信息。
与 LongPressDraggable
相关联的 data
会被传递到一个名为 DragTarget
的特殊 widget 上,用户在此释放拖动手势。接下来你将实现拖放行为。
放下拖动项
#用户可以将 LongPressDraggable
放在任意位置,但除非将其放在 DragTarget
之上,否则拖放操作不会有任何效果。当用户将一个可拖放的 widget 放在 DragTarget
widget 上时,
DragTarget
可以选择接受或拒绝来自可拖放 widget 的数据。
在本示例中,用户应将菜单项拖放到 CustomerCart
widget 上,从而将菜单项添加到用户的购物车中。
CustomerCart(
hasItems: customer.items.isNotEmpty,
highlighted: candidateItems.isNotEmpty,
customer: customer,
);
用 DragTarget
widget 包裹 CustomCart
widget。
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
显示你现有的 widget,并与 LongPressDraggable
协调,识别用户何时将可拖放的 widget 拖动到 DragTarget
之上。同时,DragTarget
也能识别用户何时在其上方放下一个可拖放的 widget。
当用户将可一个可拖放的 widget 拖动到 DragTarget
widget 上时,
candidateItems
包含用户正在拖动的数据项。你可以根据用户拖动的情况更改 widget 的外观。在本例中,Customer
widget 在有项目拖动到 DragTarget
widget 上方时会变成红色。这个红色的外观是通过 CustomerCart
widget 中的 highlighted
属性配置的。
当用户将一个可拖放的 widget 放在 DragTarget
widget 上时,会调用 onAcceptWithDetails
回调。这时你可以决定是否接受拖放的数据。在本例中,数据项总是会被接受和处理。你也可以选择检查传入的数据项,以做出不同的决定。
注意,拖放到 DragTarget
上的数据项类型必须与从 LongPressDraggable
拖出的数据项类型匹配。如果类型不兼容,onAcceptWithDetails
方法将不会被调用。
通过配置 DragTarget
widget 来接受你所需的数据,现在你可以通过拖放在 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
widget 根据一个 Customer
实例显示顾客的照片、姓名、总价和物品数量。
要在菜单项被拖放时更新顾客的购物车,需要将放下的物品添加到关联的 Customer
对象中。
void _itemDroppedOnCustomerCart({
required Item item,
required Customer customer,
}) {
setState(() {
customer.items.add(item);
});
}
当用户将菜单项拖放到 CustomerCart
widget 上时,
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: 'Spinach Pizza',
totalPriceCents: 1299,
uid: '1',
imageProvider: NetworkImage('https://docs.flutter.dev'
'/cookbook/img-files/effects/split-check/Food1.jpg'),
),
Item(
name: 'Veggie Delight',
totalPriceCents: 799,
uid: '2',
imageProvider: NetworkImage('https://docs.flutter.dev'
'/cookbook/img-files/effects/split-check/Food2.jpg'),
),
Item(
name: 'Chicken Parmesan',
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(
'Order Food',
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 的最新稳定版本,本页面最后更新时间: 2024-09-16。 查看文档源码 或者 为本页面内容提出建议。