如何在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
相关产品推荐
相关产品推荐

