跳到主內容

拖動 UI 元素

如何實現可拖拽的 UI 元素。

拖拽(Drag and drop)是移動端應用中一種常見的互動方式。當用戶長按(有時稱為“觸控並按住”)某個元件時,該元件下方會出現另一個元件,使用者可以將該元件拖拽到最終位置並釋放。在本示例中,你將構建一個拖拽互動,使用者長按選擇的食物,然後將其拖拽到正在付款的顧客圖片上。

以下動畫展示了應用程式的行為

Ordering the food by dragging it to the person

本示例從一個預構建的選單項列表和一行顧客列表開始。第一步是識別長按動作並顯示一個可拖拽的選單項照片。

按下並拖拽

#

Flutter 提供了一個名為 LongPressDraggable 的元件,它能提供啟動拖拽互動所需的確切行為。LongPressDraggable 元件可以識別長按事件,並在使用者手指附近顯示一個新元件。當用戶拖拽時,該元件會跟隨使用者的手指移動。LongPressDraggable 讓你能夠完全控制被使用者拖拽的元件。

每個選單列表項都透過一個自定義的 MenuListItem 元件進行顯示。

dart
MenuListItem(
  name: item.name,
  price: item.formattedTotalItemPrice,
  photoProvider: item.imageProvider,
)

使用 LongPressDraggable 元件包裹 MenuListItem 元件。

dart
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 的特殊元件,使用者會在該元件上釋放拖拽手勢。接下來,你將實現放置(drop)行為。

放置被拖拽元素

#

使用者可以將 LongPressDraggable 放置在他們選擇的任何位置,但除非放置在 DragTarget 上,否則拖拽不會產生任何效果。當用戶將可拖拽元件放置在 DragTarget 元件上時,DragTarget 可以選擇接受或拒絕來自拖拽元件的資料。

在本示例中,使用者應該將選單項放置在 CustomerCart 元件上,以將選單項新增到使用者的購物車中。

dart
CustomerCart(
  hasItems: customer.items.isNotEmpty,
  highlighted: candidateItems.isNotEmpty,
  customer: customer,
);

使用 DragTarget 元件包裹 CustomerCart 元件。

dart
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 物件表示,該物件維護著一個商品購物車和總價格。

dart
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 物件中。

dart
void _itemDroppedOnCustomerCart({
  required Item item,
  required Customer customer,
}) {
  setState(() {
    customer.items.add(item);
  });
}

_itemDroppedOnCustomerCart 方法在使用者將選單項放置到 CustomerCart 元件上時,於 onAcceptWithDetails() 中被呼叫。透過將放置的專案新增到 customer 物件並呼叫 setState() 來觸發佈局更新,UI 將重新整理以顯示新的顧客總價格和商品數量。

恭喜!你已經完成了一個可以將食物新增到顧客購物車的拖拽互動。

互動示例

#

執行應用

  • 滾動檢視食物列表。
  • 用手指按住其中一個,或者使用滑鼠點選並按住。
  • 按住時,食物的圖片會出現在列表上方。
  • 拖拽該圖片並將其放置在螢幕底部的某位顧客上。圖片下方的文字會更新,以反映該顧客的費用。你可以繼續新增食物,並觀察費用的累加。
import 'package:flutter/material.dart';

void main() {
  runApp(
    const MaterialApp(
      home: ExampleDragAndDrop(),
      debugShowCheckedModeBanner: false,
    ),
  );
}

const _urlPrefix =
    'https://docs.flutter.club.tw/assets/images/exercise/effects/split-check';

const List<Item> _items = [
  Item(
    name: 'Spinach Pizza',
    totalPriceCents: 1299,
    uid: '1',
    imageProvider: NetworkImage('$_urlPrefix/Food1.jpg'),
  ),
  Item(
    name: 'Veggie Delight',
    totalPriceCents: 799,
    uid: '2',
    imageProvider: NetworkImage('$_urlPrefix/Food2.jpg'),
  ),
  Item(
    name: 'Chicken Parmesan',
    totalPriceCents: 1499,
    uid: '3',
    imageProvider: NetworkImage('$_urlPrefix/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('$_urlPrefix/Avatar1.jpg'),
    ),
    Customer(
      name: 'Nathan',
      imageProvider: const NetworkImage('$_urlPrefix/Avatar2.jpg'),
    ),
    Customer(
      name: 'Emilio',
      imageProvider: const NetworkImage('$_urlPrefix/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)}';
  }
}