拖曳 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,
)
將 MenuListItem
Widget 包裝在 LongPressDraggable
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,使用者會在該 Widget 上放開拖曳手勢。接下來,您將實作放下行為。
放下可拖曳項目
#使用者可以將 LongPressDraggable
放到他們選擇的任何位置,但除非將可拖曳項目放在 DragTarget
上,否則放下可拖曳項目不會產生任何影響。當使用者將可拖曳項目放在 DragTarget
Widget 上時,DragTarget
Widget 可以接受或拒絕來自可拖曳項目的資料。
在本食譜中,使用者應將選單項目放在 CustomerCart
Widget 上,以將選單項目新增至使用者的購物車。
CustomerCart(
hasItems: customer.items.isNotEmpty,
highlighted: candidateItems.isNotEmpty,
customer: customer,
);
將 CustomerCart
Widget 包裝在 DragTarget
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
協調,以辨識使用者何時將可拖曳項目拖曳到 DragTarget
上。DragTarget
也會辨識使用者何時將可拖曳項目放在 DragTarget
Widget 上。
當使用者將可拖曳項目拖曳到 DragTarget
Widget 上時,candidateItems
會包含使用者正在拖曳的資料項目。此可拖曳項目可讓您變更 Widget 在使用者將它拖曳過時的外觀。在此情況下,只要任何項目被拖曳到 DragTarget
Widget 上方,Customer
Widget 就會變成紅色。紅色視覺外觀是在 CustomerCart
Widget 中使用 highlighted
屬性設定。
當使用者將可拖曳項目放在 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://flutter-docs.dev.org.tw'
'/cookbook/img-files/effects/split-check/Food1.jpg'),
),
Item(
name: 'Veggie Delight',
totalPriceCents: 799,
uid: '2',
imageProvider: NetworkImage('https://flutter-docs.dev.org.tw'
'/cookbook/img-files/effects/split-check/Food2.jpg'),
),
Item(
name: 'Chicken Parmesan',
totalPriceCents: 1499,
uid: '3',
imageProvider: NetworkImage('https://flutter-docs.dev.org.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://flutter-docs.dev.org.tw'
'/cookbook/img-files/effects/split-check/Avatar1.jpg'),
),
Customer(
name: 'Nathan',
imageProvider: const NetworkImage('https://flutter-docs.dev.org.tw'
'/cookbook/img-files/effects/split-check/Avatar2.jpg'),
),
Customer(
name: 'Emilio',
imageProvider: const NetworkImage('https://flutter-docs.dev.org.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)}';
}
}
除非另有說明,否則本網站上的文件反映 Flutter 的最新穩定版本。頁面最後更新於 2024-07-07。 檢視原始碼 或 回報問題。