You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Flutter的PageView.builder中实现半图+全图轮播效果?

需求说明
  • 每个PageView页面需展示一辆完整尺寸的车辆图片和一辆半尺寸的车辆图片
  • 页面切换时需带有平滑的动画过渡效果
  • 用户选择车型时,车辆图片需按照上述布局形式进行切换

现有TabBarView代码

TabBarView(
  children: serviceCategories.map((e) {
    return PageView(
      children: e.services
          .map((e) => SizedBox(
                    height: 225,
                    //width: MediaQuery.of(context).size.width * 0.1,
                    child: PageView.builder(
                      scrollDirection: Axis.horizontal,
                      itemBuilder: (context, index) =>
                          ServiceItemView(
                              isSelected: e.id ==
                                  (mainBloc.state as OrderPreview)
                                      .selectedService
                                      ?.id,
                              service: e,
                              currency: currency),
                      itemCount: serviceCategories
                          .map((e) => e)
                          .toList()
                          .length,
                    ),
                  ) /*ServiceItemView(
                service: e,
                isSelected: e.id ==
                    (mainBloc.state as OrderPreview)
                        .selectedService
                        ?.id,
                currency: currency,
              )*/
              )
          .toList(),
    );
  }).toList(),
),

现有ServiceItemView组件代码

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:ridy/app_colors.dart';
import '../config.dart';
import '../graphql/generated/graphql_api.graphql.dart';
import 'package:velocity_x/velocity_x.dart';
import 'package:intl/intl.dart';
import 'package:client_shared/theme/theme.dart';
import 'bloc/main_bloc.dart';

class ServiceItemView extends StatelessWidget {
  final bool isSelected;
  final GetFare$Query$CalculateFareDTO$ServiceCategory$Service service;
  final String currency;

  const ServiceItemView(
      {Key? key,
      required this.isSelected,
      required this.service,
      required this.currency})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    final mainBloc = context.read<MainBloc>();
    return GestureDetector(
      onTap: () => mainBloc.add(SelectService(service)),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 250),
        padding: const EdgeInsets.symmetric(horizontal: 4),
        decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(8),
            color: isSelected
                ? CustomTheme.primaryColors.shade100
                : CustomTheme.primaryColors.shade50),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Container(
              height: 150,
              width: MediaQuery.of(context).size.width * 0.6,
              decoration: BoxDecoration(
                  image: DecorationImage(
                      fit: BoxFit.cover,
                      image: NetworkImage(
                        serverUrl + service.media.address,
                      ))),
            ),
            // Image.network(
            //   serverUrl + service.media.address,
            //   width: MediaQuery.of(context).size.width,
            //   height: 150,
            // ),
            Row(
              children: [
                Text(
                  service.cost.toStringAsFixed(2),
                  style: Theme.of(context).textTheme.titleLarge?.copyWith(
                      fontSize: 25,
                      fontWeight: FontWeight.normal,
                      decoration: (service.costAfterCoupon != null &&
                              service.cost != service.costAfterCoupon)
                          ? TextDecoration.lineThrough
                          : TextDecoration.none,
                      color: (service.costAfterCoupon != null &&
                              service.cost != service.costAfterCoupon)
                          ? CustomTheme.neutralColors.shade300
                          : //CustomTheme.neutralColors.shade800
                          AppColors.appMainColor),
                ),
                Text(
                  ' $currency',
                  style: Theme.of(context).textTheme.titleMedium?.copyWith(
                        fontSize: 25,
                        fontWeight: FontWeight.normal,
                        color: (service.costAfterCoupon != null &&
                                service.cost != service.costAfterCoupon)
                            ? CustomTheme.neutralColors.shade300
                            : //CustomTheme.neutralColors.shade800
                            AppColors.appMainColor,
                      ),
                ),
              ],
            ),
            if (service.costAfterCoupon != null &&
                service.cost != service.costAfterCoupon)
              Row(
                children: [
                  Text(
                    service.costAfterCoupon.toString(),
                    style: Theme.of(context)
                        .textTheme
                        .titleMedium
                        ?.copyWith(color: const Color(0xff108910)),
                  ),
                  Text(
                    currency,
                    style: Theme.of(context)
                        .textTheme
                        .titleMedium
                        ?.copyWith(color: const Color(0xff108910)),
                  ),
                ],
              ).pOnly(top: 4),

            // Text(
            //   NumberFormat.simpleCurrency(name: currency).format(service.cost),
            //   style: Theme.of(context).textTheme.titleMedium?.copyWith(
            //       decoration: (service.costAfterCoupon != null &&
            //               service.cost != service.costAfterCoupon)
            //           ? TextDecoration.lineThrough
            //           : TextDecoration.none,
            //       color: (service.costAfterCoupon != null &&
            //               service.cost != service.costAfterCoupon)
            //           ? CustomTheme.neutralColors.shade300
            //           : CustomTheme.neutralColors.shade800),
            // ),
            // if (service.costAfterCoupon != null &&
            //     service.cost != service.costAfterCoupon)
            //   Text(
            //     NumberFormat.simpleCurrency(name: currency)
            //         .format(service.costAfterCoupon),
            //     style: Theme.of(context)
            //         .textTheme
            //         .titleMedium
            //         ?.copyWith(color: const Color(0xff108910)),
            //   ).pOnly(top: 4)
          ],
        ),
        // child: Row(
        //   children: [
        //     Image.network(
        //       serverUrl + service.media.address,
        //       width: 75,
        //       height: 75,
        //     ),
        //     Padding(
        //       padding: const EdgeInsets.symmetric(horizontal: 8),
        //       child: Column(
        //         crossAxisAlignment: CrossAxisAlignment.start,
        //         children: [
        //           Text(
        //             service.name,
        //             style: Theme.of(context).textTheme.titleMedium,
        //           ),
        //           if (service.description?.isNotEmpty ?? false)
        //             Text(
        //               service.description ?? "",
        //               style: Theme.of(context).textTheme.labelMedium,
        //             ).pOnly(top: 4)
        //         ],
        //       ),
        //     ),
        //     const Spacer(),
        //     Column(
        //       crossAxisAlignment: CrossAxisAlignment.center,
        //       children: [
        //         Text(
        //           NumberFormat.simpleCurrency(name: currency)
        //               .format(service.cost),
        //           style: Theme.of(context).textTheme.titleMedium?.copyWith(
        //               decoration: (service.costAfterCoupon != null &&
        //                       service.cost != service.costAfterCoupon)
        //                   ? TextDecoration.lineThrough
        //                   : TextDecoration.none,
        //               color: (service.costAfterCoupon != null &&
        //                       service.cost != service.costAfterCoupon)
        //                   ? CustomTheme.neutralColors.shade300
        //                   : CustomTheme.neutralColors.shade800),
        //         ),
        //         if (service.costAfterCoupon != null &&
        //             service.cost != service.costAfterCoupon)
        //           Text(
        //             NumberFormat.simpleCurrency(name: currency)
        //                 .format(service.costAfterCoupon),
        //             style: Theme.of(context)
        //                 .textTheme
        //                 .titleMedium
        //                 ?.copyWith(color: const Color(0xff108910)),
        //           ).pOnly(top: 4)
        //       ],
        //     ).pOnly(top: 4, right: 8)
        //   ],
        // ),
      ),
    );
  }
}

实现方案

1. 重构TabBarView内的PageView结构

简化嵌套PageView结构,通过PageController控制可见区域,配合缩放动画实现“一整一半”的显示效果:

TabBarView(
  children: serviceCategories.map((category) {
    final services = category.services;
    return PageView.builder(
      scrollDirection: Axis.horizontal,
      controller: PageController(viewportFraction: 0.7), // 控制可见区域比例,实现同时显示多个item
      itemCount: services.length,
      onPageChanged: (index) {
        // 页面切换时自动更新选中服务
        final selectedService = services[index];
        context.read<MainBloc>().add(SelectService(selectedService));
      },
      itemBuilder: (context, index) {
        final service = services[index];
        final isSelected = service.id == (mainBloc.state as OrderPreview).selectedService?.id;
        
        // 根据选中状态缩放item,实现完整/半尺寸效果
        return Transform.scale(
          scale: isSelected ? 1.0 : 0.7,
          child: ServiceItemView(
            isSelected: isSelected,
            service: service,
            currency: currency,
          ),
        );
      },
    );
  }).toList(),
),

2. 调整ServiceItemView组件

优化布局和动画,适配新的显示逻辑:

class ServiceItemView extends StatelessWidget {
  final bool isSelected;
  final GetFare$Query$CalculateFareDTO$ServiceCategory$Service service;
  final String currency;

  const ServiceItemView({
    Key? key,
    required this.isSelected,
    required this.service,
    required this.currency,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final mainBloc = context.read<MainBloc>();
    return GestureDetector(
      onTap: () => mainBloc.add(SelectService(service)),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeInOut,
        padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(12),
          color: isSelected
              ? CustomTheme.primaryColors.shade100
              : CustomTheme.primaryColors.shade50,
          // 选中时添加阴影增强区分度
          boxShadow: isSelected
              ? [
                  BoxShadow(
                    color: CustomTheme.primaryColors.shade200,
                    blurRadius: 8,
                    offset: const Offset(0, 2),
                  )
                ]
              : null,
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            Expanded(
              child: Container(
                width: double.infinity,
                decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(8),
                  image: DecorationImage(
                    fit: BoxFit.contain, // 避免车辆图片拉伸变形
                    image: NetworkImage(serverUrl + service.media.address),
                  ),
                ),
              ),
            ),
            const SizedBox(height: 8),
            // 价格展示区域
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                  service.cost.toStringAsFixed(2),
                  style: Theme.of(context).textTheme.titleLarge?.copyWith(
                        fontSize: 22,
                        decoration: (service.costAfterCoupon != null &&
                                service.cost != service.costAfterCoupon)
                            ? TextDecoration.lineThrough
                            : TextDecoration.none,
                        color: (service.costAfterCoupon != null &&
                                service.cost != service.costAfterCoupon)
                            ? CustomTheme.neutralColors.shade300
                            : AppColors.appMainColor,
                      ),
                ),
                Text(
                  " $currency",
                  style: Theme.of(context).textTheme.titleMedium?.copyWith(
                        fontSize: 18,
                        color: (service.costAfterCoupon != null &&
                                service.cost != service.costAfterCoupon)
                            ? CustomTheme.neutralColors.shade300
                            : AppColors.appMainColor,
                      ),
                ),
              ],
            ),
            if (service.costAfterCoupon != null &&
                service.cost != service.costAfterCoupon)
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text(
                    service.costAfterCoupon.toString(),
                    style: Theme.of(context)
                        .textTheme
                        .titleMedium
                        ?.copyWith(color: const Color(0xff108910)),
                  ),
                  Text(
                    currency,
                    style: Theme.of(context)
                        .textTheme
                        .titleMedium
                        ?.copyWith(color: const Color(0xff108910)),
                  ),
                ],
              ).pOnly(top: 4),
          ],
        ),
      ),
    );
  }
}

关键实现说明

  • PageController(viewportFraction: 0.7):让PageView同时显示多个item,自然呈现“一整一半”的布局
  • Transform.scale:根据选中状态动态调整item尺寸,配合AnimatedContainer实现平滑过渡动画
  • onPageChanged:页面切换时自动同步Bloc的选中状态,实现视图与状态联动
  • BoxFit.contain:保证车辆图片完整显示,避免拉伸变形

内容的提问来源于stack exchange,提问作者Farid Ahmadov

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.18 12:08:09