拖動 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: 'Spinach Pizza',
totalPriceCents: 1299,
uid: '1',
imageProvider: NetworkImage(
'https://docs.flutter.club.tw'
'/cookbook/img-files/effects/split-check/Food1.jpg',
),
),
Item(
name: 'Veggie Delight',
totalPriceCents: 799,
uid: '2',
imageProvider: NetworkImage(
'https://docs.flutter.club.tw'
'/cookbook/img-files/effects/split-check/Food2.jpg',
),
),
Item(
name: 'Chicken Parmesan',
totalPriceCents: 1499,
uid: '3',
imageProvider: NetworkImage(
'https://docs.flutter.club.tw'
'/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.club.tw'
'/cookbook/img-files/effects/split-check/Avatar1.jpg',
),
),
Customer(
name: 'Nathan',
imageProvider: const NetworkImage(
'https://docs.flutter.club.tw'
'/cookbook/img-files/effects/split-check/Avatar2.jpg',
),
),
Customer(
name: 'Emilio',
imageProvider: const NetworkImage(
'https://docs.flutter.club.tw'
'/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)}';
}
}