当前位置: 首页 > news >正文

学习 Flutter (四):玩安卓项目实战 - 中

学习 Flutter(四):玩安卓项目实战 - 中

在上一章节,我们是已经搭建完了玩安卓项目的基础架构,并且完成了登录注册界面,并成功进入到了主界面,这一章节我们将逐步完成主界面的首页、体系、导航、项目界面的编码。

一、首页

首先,我们先看看首页模块界面到底要设计成什么样子,我们可以在网上看到很多玩安卓项目相关的博客,我们就借鉴一下界面,完成如下所示的界面设计

在这里插入图片描述

可以发现,这个界面分为三个部分,一个是顶部导航栏(标题 + 搜索框),一个是首页轮播图、一个是首页文章列表。我们来逐步进行分析,顶部导航栏,在上一篇文章我们就已经写出来了,毕竟这个是属于顶层容器的组件 CustomAppBar ,顶部导航栏这边,作者只做了简单的设计,内容只有导航标题和一个跳转进入搜索界面的搜索按钮,导航标题显示对模块的标题,搜索按钮在我的模块下进行隐藏,接下来作者将详细分析一下首页轮播图和首页文章列表的设计

1.1 首页轮播图

作者这边尽量不是使用第三方的库,所以想要实现轮播图、文字/图标指示器、点击事件、轮播、圆角这些,需要花点时间去进行自定义,首先我们先看看指示器相关的组件

base/base_widget.dart

BaseWidget 是一个 可配置性极强的 UI 组件基类,封装了 Flutter 中大部分常见的容器样式和交互行为

class BaseWidget extends StatelessWidget {final VoidCallback? onClick; //点击事件final VoidCallback? onDoubleClick; //双击事件final VoidCallback? onLongPress; //长按事件final double? width; //宽度final double? height; //高度final double? margin; //外边距,左上右下final double? marginLeft; //外边距,距离左边final double? marginTop; //外边距,距离上边final double? marginRight; //外边距,距离右边final double? marginBottom; //外边距,距离下边final double? padding; //内边距,左上右下final double? paddingLeft; //内边距,距离左边final double? paddingTop; //内边距,距离上边final double? paddingRight; //内边距,距离右边final double? paddingBottom; //内边距,距离下边final Color? backgroundColor; //背景颜色 和 decoration 二者取其一final double? strokeWidth; //背景边框统一的宽度final Color? strokeColor; //背景边框的颜色final Color? solidColor; //背景填充颜色final double? radius; //背景的角度final bool? isCircle; //背景是否是圆形final double? leftTopRadius; //背景左上角度final double? rightTopRadius; //背景 右上角度final double? leftBottomRadius; //背景 左下角度final double? rightBottomRadius; //背景 右下角度final Widget? childWidget; //子控件final Alignment? alignment; //位置final int? gradient; //渐变方式,为支持后续拓展,用int类型final List<Color>? gradientColorList; //渐变颜色final List<double>? gradientColorStops; //颜色值梯度,取值范围[0,1]final Alignment? gradientBegin; //渐变起始位置final Alignment? gradientEnd; //渐变结束位置//边框的颜色const BaseWidget({super.key,this.width,this.height,this.margin,this.marginLeft,this.marginTop,this.marginRight,this.marginBottom,this.padding,this.paddingLeft,this.paddingTop,this.paddingRight,this.paddingBottom,this.backgroundColor,this.strokeWidth,this.strokeColor,this.solidColor,this.radius,this.isCircle,this.leftTopRadius,this.rightTopRadius,this.leftBottomRadius,this.rightBottomRadius,this.childWidget,this.alignment,this.gradient,this.gradientColorList,this.gradientColorStops,this.gradientBegin,this.gradientEnd,this.onClick,this.onDoubleClick,this.onLongPress});Widget build(BuildContext context) {return InkWell(highlightColor: Colors.transparent,// 透明色splashColor: Colors.transparent,// 透明色onTap: onClick,onDoubleTap: onDoubleClick,onLongPress: onLongPress,child: Container(width: width,height: height,alignment: alignment,margin: margin != null? EdgeInsets.all(margin!): EdgeInsets.only(left: marginLeft != null ? marginLeft! : 0,top: marginTop != null ? marginTop! : 0,right: marginRight != null ? marginRight! : 0,bottom: marginBottom != null ? marginBottom! : 0),padding: padding != null? EdgeInsets.all(padding!): EdgeInsets.only(left: paddingLeft != null ? paddingLeft! : 0,top: paddingTop != null ? paddingTop! : 0,right: paddingRight != null ? paddingRight! : 0,bottom: paddingBottom != null ? paddingBottom! : 0,),color: backgroundColor,decoration: backgroundColor != null ? null : getDecoration(),child: childWidget ?? getWidget(context),));}/** 获取Decoration* */Decoration? getDecoration() {BorderRadiusGeometry? borderRadiusGeometry;if (radius != null) {//所有的角度borderRadiusGeometry = BorderRadius.all(Radius.circular(radius!));} else {//否则就是,各个角度borderRadiusGeometry = BorderRadius.only(topLeft: Radius.circular(leftTopRadius != null ? leftTopRadius! : 0),topRight:Radius.circular(rightTopRadius != null ? rightTopRadius! : 0),bottomLeft:Radius.circular(leftBottomRadius != null ? leftBottomRadius! : 0),bottomRight: Radius.circular(rightBottomRadius != null ? rightBottomRadius! : 0));}Gradient? tGradient;if (gradient != null) {tGradient = LinearGradient(colors: gradientColorList != null ? gradientColorList! : [],// 设置有哪些渐变色begin: gradientBegin != null ? gradientBegin! : Alignment.centerLeft,// 渐变色开始的位置,默认 centerLeftend: gradientEnd != null ? gradientEnd! : Alignment.centerRight,// 渐变色结束的位置,默认 centerRightstops: gradientColorStops, // 颜色值梯度,取值范围[0,1],长度要和 colors 的长度一样);}Decoration? widgetDecoration = BoxDecoration(gradient: tGradient,//背景颜色color: solidColor != null ? solidColor! : Colors.transparent,//圆角半径borderRadius: isCircle == true ? null : borderRadiusGeometry,//是否是圆形shape: isCircle == true ? BoxShape.circle : BoxShape.rectangle,//边框线宽、颜色border: Border.all(width: strokeWidth != null ? strokeWidth! : 0,color: strokeColor != null ? strokeColor! : Colors.transparent),);return widgetDecoration;}/** 获取控件* */Widget? getWidget(BuildContext context) {return null;}
}
  • EdgeInsets 是 Flutter 中用来表示 边距(margin)或内边距(padding) 的类

  • Decoration 是用来修饰组件外观的类,可以设置背景色、边框、圆角、阴影、渐变等视觉效果

  • Gradient(渐变) 是用来在组件背景中创建颜色平滑过渡效果的类,通常配合 BoxDecoration.gradient 使用。

bean/text_rich_bean.dart

/// 文本控件之富文本对象
class TextRichBean {String? text; //文字Color? textColor; //文字颜色double? textSize; //文字大小String? link; //文字链接TextRichBean({this.text, this.textColor, this.textSize, this.link});
}

app/constants.dart

/// 尺寸常量
class DimenConstant {static const double dimen_5 = 5;static const double dimen_10 = 10;static const double dimen_15 = 15;static const double dimen_22 = 22;static const double dimen_44 = 44;
}/// 图片地址常量
class ImageConstant {static const String buttonProgress = "assets/images/vp_ic_button_progress.webp";static const String buttonSelectProgress = "assets/images/vp_ic_button_select_progress.webp";static const String buttonWarnProgress = "assets/images/vp_ic_button_warn_progress.webp";static const String buttonLeftDownLoad = "assets/images/vp_ic_button_down_load.webp";static const String buttonLeftSendInvite = "assets/images/vp_ic_button_send_invite.webp";static const String imageDefault = "assets/images/vp_ic_image_default.png";static const String ratingNormal = "assets/images/vp_ic_rating_normal.webp";static const String ratingSelected = "assets/images/vp_ic_rating_selected.webp";
}

widgets/vp_text.dart

/// 一个支持可配置图标(上下左右)及富文本的通用文本组件
class VpText extends BaseWidget {// 普通文本内容final String text;// 普通文本样式final TextStyle? style;// 左侧图标资源路径(可为网络或本地)final String? leftIcon;// 左侧图标宽度final double? leftIconWidth;// 左侧图标高度final double? leftIconHeight;// 左图标与右侧文字之间的间距final double? iconMarginRight;// 右侧图标资源路径final String? rightIcon;// 右侧图标宽度final double? rightIconWidth;// 右侧图标高度final double? rightIconHeight;// 右图标与左侧文字之间的间距final double? iconMarginLeft;// 顶部图标资源路径final String? topIcon;// 顶部图标宽度final double? topIconWidth;// 顶部图标高度final double? topIconHeight;// 顶图标与下方文字之间的间距final double? iconMarginBottom;// 底部图标资源路径final String? bottomIcon;// 底部图标宽度final double? bottomIconWidth;// 底部图标高度final double? bottomIconHeight;// 底图标与上方文字之间的间距final double? iconMarginTop;// 主轴对齐方式(用于 Row/Column)final MainAxisAlignment? mainAxisAlignment;// 文本溢出时的处理方式final TextOverflow? textOverflow;// 文本对齐方式final TextAlign? textAlign;// 文本最大行数final int? maxLines;// 富文本内容列表final List<TextRichBean>? richList;// 富文本点击回调final Function(int, TextRichBean)? onRichClick;const VpText(this.text, {super.key,this.style,this.leftIcon,this.leftIconWidth = DimenConstant.dimen_22,this.leftIconHeight = DimenConstant.dimen_22,this.iconMarginRight = 0,this.rightIcon,this.rightIconWidth = DimenConstant.dimen_22,this.rightIconHeight = DimenConstant.dimen_22,this.iconMarginLeft = 0,this.topIcon,this.topIconWidth = DimenConstant.dimen_22,this.topIconHeight = DimenConstant.dimen_22,this.iconMarginBottom = 0,this.bottomIcon,this.bottomIconWidth = DimenConstant.dimen_22,this.bottomIconHeight = DimenConstant.dimen_22,this.iconMarginTop = 0,this.mainAxisAlignment = MainAxisAlignment.center,this.textOverflow,this.textAlign,this.maxLines,this.richList,this.onRichClick,// BaseWidget 的参数(布局、样式、点击等)super.width,super.height,super.margin,super.marginLeft,super.marginTop,super.marginRight,super.marginBottom,super.padding,super.paddingLeft,super.paddingTop,super.paddingRight,super.paddingBottom,super.backgroundColor,super.strokeWidth,super.strokeColor,super.solidColor,super.radius,super.isCircle,super.leftTopRadius,super.rightTopRadius,super.leftBottomRadius,super.rightBottomRadius,super.childWidget,super.alignment,super.onClick,super.onDoubleClick,super.onLongPress,});/// 获取图标对应的组件(支持网络与本地资源)Widget getImageWidget(String icon, double width, double height) {if (icon.contains("http")) {return Image.network(icon, width: width, height: height);} else {return Image.asset(icon, width: width, height: height);}}/// 构建组件主体Widget getWidget(BuildContext context) {List<Widget> widgets = [];// 如果有左图标,添加到组件列表if (leftIcon != null) {widgets.add(getImageWidget(leftIcon!, leftIconWidth!, leftIconHeight!));}// 如果左右图标存在,则将文字放中间并添加边距if (leftIcon != null || rightIcon != null) {widgets.add(Container(margin: EdgeInsets.only(left: iconMarginRight!, right: iconMarginLeft!),child: getTextWidget(),));}// 如果有右图标,添加到组件列表if (rightIcon != null) {widgets.add(getImageWidget(rightIcon!, rightIconWidth!, rightIconHeight!));}// 如果左右任一图标存在,使用 Row 布局返回if (widgets.isNotEmpty) {return Row(mainAxisAlignment: mainAxisAlignment!,children: widgets,);}// 重置 widgets 列表用于垂直方向图标布局if (topIcon != null) {widgets.add(getImageWidget(topIcon!, topIconWidth!, topIconHeight!));}// 如果上下图标存在,将文字放中间if (topIcon != null || bottomIcon != null) {widgets.add(Container(margin: EdgeInsets.only(top: iconMarginBottom!, bottom: iconMarginTop!),child: getTextWidget(),));}// 添加底部图标if (bottomIcon != null) {widgets.add(getImageWidget(bottomIcon!, bottomIconWidth!, bottomIconHeight!));}// 如果上下任一图标存在,使用 Column 布局返回if (widgets.isNotEmpty) {return Column(mainAxisAlignment: mainAxisAlignment!,children: widgets,);}// 如果设置了富文本,构建并返回富文本组件if (richList != null && richList!.isNotEmpty) {List<TextSpan> list = [];for (var a = 0; a < richList!.length; a++) {var richBean = richList![a];var textSpan = TextSpan(text: richBean.text,recognizer: TapGestureRecognizer()..onTap = () {if (onRichClick != null) {onRichClick!(a, richBean);}},style: TextStyle(fontSize: richBean.textSize,color: richBean.textColor,),);list.add(textSpan);}return Text.rich(TextSpan(children: list));}// 默认仅返回普通文本组件return getTextWidget();}/// 构建普通文本组件Widget getTextWidget() {return Text(text,overflow: textOverflow,textAlign: textAlign,maxLines: maxLines,style: style,);}
}

定义了一个通用、灵活、可复用的 Flutter 文本组件 VpText基于 BaseWidget 进行扩展,不仅可以显示普通文字,还可以:

  • 自动添加上下左右的图标

  • 支持富文本点击

  • 控制排版方式(Row / Column)

  • 支持完整的布局、边距、样式、装饰等配置

widgets/vp_banner.dart

/// 通用轮播图组件(支持图片轮播、指示器、标题、圆角、点击事件、手势控制等)
class VpBanner extends StatefulWidget {// 图片资源列表(必传)final List<String>? imageList;// 可选标题集合,对应每张图的标题final List<String>? titleList;// 圆角大小final double? radius;// banner 高度final double? height;// 单独设置图片边距(四边)final double? imageMarginLeft,imageMarginTop,imageMarginRight,imageMarginBottom,imageMargin;// 整个 banner 的边距(四边)final double? marginLeft, marginTop, marginRight, marginBottom, margin;// 指示器距离底部、左右的边距final double? indicatorMarginBottom,indicatorMarginRight,indicatorMarginLeft;// 指示器颜色final Color? indicatorSelectColor, indicatorUnSelectColor;// 指示器尺寸(选中与未选中)final double? indicatorWidth,indicatorHeight,indicatorUnWidth,indicatorUnHeight;// 指示器之间的间距final double? indicatorMargin;// 指示器样式类型(圆形/矩形/文字)final IndicatorType? indicatorType;// 指示器圆角(矩形时有效)final double? indicatorRadius;// 指示器是否显示在 banner 下方区域final bool? indicatorBannerBottom;// banner 下方的指示器区域背景色与尺寸、边距final Color? indicatorBottomColor;final double? indicatorBottomHeight;final double? indicatorBottomMarginLeft;final double? indicatorBottomMarginRight;// banner 下方指示器的对齐方式final MainAxisAlignment indicatorBottomMainAxisAlignment;// 自动轮播时间间隔(单位:秒)final int? delay;// 是否启用自动轮播final bool? autoPlay;// 是否显示指示器final bool? showIndicators;// 点击事件回调,返回当前 indexfinal Function(int)? bannerClick;// 页面缩进程度(控制左右滑动效果)final double? viewportFraction;// 文字指示器对齐方式、样式、背景色、内边距final Alignment? textIndicatorAlignment;final TextStyle? textIndicatorStyle;final Color? textIndicatorBgColor;final double? textIndicatorPadding;final double? textIndicatorPaddingLeft,textIndicatorPaddingTop,textIndicatorPaddingRight,textIndicatorPaddingBottom;// 标题背景色、高度、样式、对齐方式、底部间距final Color? titleBgColor;final double? titleHeight;final Alignment? titleAlignment;final TextStyle? titleStyle;final double? titleMarginBottom;// 非当前页图片缩放比例final double? bannerOtherScale;// 占位图、错误图final String? placeholderImage;final String? errorImage;// 图片适应方式final BoxFit? imageBoxFit;const VpBanner({super.key,required this.imageList,this.radius = 0,this.height = 150,this.marginLeft = 0,this.marginTop = 0,this.marginRight = 0,this.marginBottom = 0,this.margin,this.imageMarginLeft = 0,this.imageMarginTop = 0,this.imageMarginRight = 0,this.imageMarginBottom = 0,this.imageMargin,this.indicatorMarginBottom = 10,this.indicatorMarginRight,this.indicatorMarginLeft,this.indicatorSelectColor = Colors.red,this.indicatorUnSelectColor = Colors.grey,this.indicatorWidth = 10,this.indicatorHeight = 10,this.indicatorMargin = 5,this.indicatorType = IndicatorType.circle,this.indicatorRadius = 0,this.indicatorUnWidth,this.indicatorUnHeight,this.indicatorBannerBottom = false,this.indicatorBottomColor = Colors.transparent,this.indicatorBottomHeight = 30,this.indicatorBottomMarginLeft = 0,this.indicatorBottomMarginRight = 0,this.indicatorBottomMainAxisAlignment = MainAxisAlignment.center,this.delay = 5,this.autoPlay = true,this.showIndicators = true,this.bannerClick,this.viewportFraction = 1,this.textIndicatorAlignment = Alignment.center,this.textIndicatorStyle,this.textIndicatorPadding,this.textIndicatorPaddingLeft = 0,this.textIndicatorPaddingTop = 0,this.textIndicatorPaddingRight = 0,this.textIndicatorPaddingBottom = 0,this.titleList,this.titleBgColor = Colors.transparent,this.titleHeight,this.titleAlignment = Alignment.centerLeft,this.titleStyle,this.titleMarginBottom = 0,this.bannerOtherScale = 1.0,this.placeholderImage,this.imageBoxFit = BoxFit.cover,this.errorImage,this.textIndicatorBgColor,});State<StatefulWidget> createState() => _CarouselState();
}class _CarouselState extends State<VpBanner> with WidgetsBindingObserver {late PageController _controller; // PageView 控制器int _currentPage = 0; // 当前显示页面int _pagePosition = 0; // 实际 PageView 位置(可能大于图片数量)Timer? _timer; // 轮播定时器bool _isRunning = false; // 定时器运行标志bool _isClick = true; // 判断是点击还是滑动/// 启动轮播定时器void _startTimer() {if (!_isRunning) {_isRunning = true;_timer = Timer.periodic(Duration(seconds: widget.delay!), (timer) {_controller.animateToPage(_pagePosition + 1,duration: const Duration(milliseconds: 800),curve: Curves.easeInOut,);});}}/// 暂停轮播定时器void _pauseTimer() {if (_isRunning) {_isRunning = false;_timer?.cancel();}}void initState() {super.initState();_controller = PageController(viewportFraction: widget.viewportFraction!);WidgetsBinding.instance.addObserver(this);if (widget.autoPlay!) {_startTimer();}}/// 应用生命周期变更(后台暂停轮播,恢复继续)void didChangeAppLifecycleState(AppLifecycleState state) {super.didChangeAppLifecycleState(state);if (state == AppLifecycleState.resumed) {_startTimer();} else if (state == AppLifecycleState.paused) {_pauseTimer();}}void dispose() {_controller.dispose();_timer?.cancel();super.dispose();}/// 页面滑动监听void _onPageChanged(int index) {var position = index % widget.imageList!.length;setState(() {_currentPage = position;_pagePosition = index;});}Widget build(BuildContext context) {// 空数据直接返回占位if (widget.imageList == null || widget.imageList!.isEmpty) {return const SizedBox(height: 150, child: Center(child: Text("暂无 Banner")));}// 构建 banner 图组件(包含手势控制)Widget bannerImage = ClipRRect(borderRadius: BorderRadius.circular(widget.radius!),child: Container(margin: widget.margin != null? EdgeInsets.all(widget.margin!): EdgeInsets.only(left: widget.marginLeft!,top: widget.marginTop!,right: widget.marginRight!,bottom: widget.marginBottom!,),height: widget.height,child: Listener(onPointerDown: (_) {_pauseTimer(); // 手指按下暂停轮播_isClick = true;},onPointerMove: (_) {_isClick = false;},onPointerUp: (_) {_startTimer(); // 手指抬起重启轮播if (_isClick && widget.bannerClick != null) {widget.bannerClick!(_currentPage);}},child: PageView.builder(controller: _controller,onPageChanged: _onPageChanged,itemBuilder: (context, index) {double scale = index % widget.imageList!.length != _currentPage? widget.bannerOtherScale!: 1.0;String imageUrl =widget.imageList![index % widget.imageList!.length];return Transform.scale(scale: scale,child: Container(margin: widget.imageMargin != null? EdgeInsets.all(widget.imageMargin!): EdgeInsets.only(left: widget.imageMarginLeft!,top: widget.imageMarginTop!,right: widget.imageMarginRight!,bottom: widget.imageMarginBottom!,),child: getBannerImage(imageUrl),),);},),),),);// 组合返回 Stack 或 Column(依据指示器位置)return !widget.indicatorBannerBottom!? Stack(children: [bannerImage,if (widget.titleList != null) bannerTitle(),if (widget.showIndicators!) getBannerIndicators(),],): Column(children: [bannerImage,if (widget.showIndicators!) getBannerBottomIndicators(),],);}/// 获取 banner 图片组件(支持占位图)Widget getBannerImage(String imageUrl) {if (widget.placeholderImage == null) {return Image.network(imageUrl, fit: widget.imageBoxFit);} else {return FadeInImage(fit: widget.imageBoxFit,placeholderFit: widget.imageBoxFit,placeholder: getPlaceholder(),image: NetworkImage(imageUrl),placeholderErrorBuilder: (_, __, ___) => _imagePlaceholder(),imageErrorBuilder: (_, __, ___) => _imageError(),);}}Widget _imagePlaceholder() =>Image.asset(ImageConstant.imageDefault, fit: widget.imageBoxFit);Widget _imageError() =>Image.asset(widget.errorImage ?? ImageConstant.imageDefault,fit: widget.imageBoxFit);ImageProvider getPlaceholder() => AssetImage(widget.placeholderImage!);/// 顶部叠加指示器(文字或点)Widget getBannerIndicators() {return Positioned(left: widget.indicatorMarginRight != null? null: widget.indicatorMarginLeft ?? 0,right: widget.indicatorMarginLeft != null? null: widget.indicatorMarginRight ?? 0,bottom: widget.indicatorMarginBottom!,child: _buildIndicators(MainAxisAlignment.center),);}/// 底部指示器区域Widget getBannerBottomIndicators() {return Container(height: widget.indicatorBottomHeight,color: widget.indicatorBottomColor,margin: EdgeInsets.only(left: widget.indicatorBottomMarginLeft!,right: widget.indicatorBottomMarginRight!,),child: _buildIndicators(widget.indicatorBottomMainAxisAlignment),);}/// 构建点状或文字型指示器Widget _buildIndicators(MainAxisAlignment mainAxisAlignment) {if (widget.indicatorType == IndicatorType.text) {return Container(alignment: widget.textIndicatorAlignment,child: VpText("${_currentPage + 1}/${widget.imageList!.length}",style: widget.textIndicatorStyle,backgroundColor: widget.textIndicatorBgColor,padding: widget.textIndicatorPadding,paddingLeft: widget.textIndicatorPaddingLeft,paddingTop: widget.textIndicatorPaddingTop,paddingRight: widget.textIndicatorPaddingRight,paddingBottom: widget.textIndicatorPaddingBottom,),);}return Row(mainAxisAlignment: mainAxisAlignment,children: List.generate(widget.imageList!.length, (index) {bool isSelected = index == _currentPage;return Container(width: isSelected? widget.indicatorWidth: widget.indicatorUnWidth ?? widget.indicatorWidth,height: isSelected? widget.indicatorHeight: widget.indicatorUnHeight ?? widget.indicatorHeight,margin: EdgeInsets.symmetric(horizontal: widget.indicatorMargin!),decoration: BoxDecoration(shape: widget.indicatorType == IndicatorType.circle? BoxShape.circle: BoxShape.rectangle,borderRadius: widget.indicatorType == IndicatorType.rectangle? BorderRadius.circular(widget.indicatorRadius!): null,color: isSelected? widget.indicatorSelectColor: widget.indicatorUnSelectColor,),);}),);}/// 显示 Banner 当前标题(可选)Widget bannerTitle() {return Positioned(bottom: widget.titleMarginBottom,left: 0,right: 0,child: VpText(widget.titleList![_currentPage],height: widget.titleHeight,backgroundColor: widget.titleBgColor,alignment: widget.titleAlignment,style: widget.titleStyle,),);}
}/// 指示器样式类型
enum IndicatorType { circle, rectangle, text }

VpBanner 组件是一个高度定制化的 Flutter Banner(轮播图)组件,实现了基本的 自动轮播页面指示器点击跳转,还支持:

  • 多种 指示器样式(圆形、矩形、文字)

  • 丰富的 边距、缩放、圆角、占位图、标题 等参数配置

  • 在生命周期中 自动暂停和恢复轮播

  • 手势监听(点击暂停/继续轮播 + 响应点击)

类/字段描述
VpBanner主组件,参数配置和生命周期入口
_CarouselStateStatefulWidget 的状态管理,轮播核心逻辑
PageView.builder实现滑动页视图
Timer自动轮播定时器
VpText用于标题文字和文字型指示器的复用组件
enum IndicatorType枚举定义三种指示器类型

至此我们轮播图相关组件就完成了,接下来我们看看如何使用,当然首先我们要进行网络请求获取到首页轮播图,这里将首页相关的网络请求都贴出来,之后不再赘述,因为要进行数据的添加,所以我们将文章列表数据进行私有保存,并提供给 View 层的获取数据方法。

添加 models/empty_response.dart

class EmptyResponse {final dynamic data;final int errorCode;final String errorMsg;EmptyResponse({required this.data,required this.errorCode,required this.errorMsg,});factory EmptyResponse.fromJson(Map<String, dynamic> json) {return EmptyResponse(data: json['data'],errorCode: json['errorCode'],errorMsg: json['errorMsg'],);}bool get success => errorCode == 0;}

添加 models/banner_response.dart

class BannerResponse {final int errorCode;final String errorMsg;final List<BannerItem> data;BannerResponse({required this.errorCode,required this.errorMsg,required this.data,});factory BannerResponse.fromJson(Map<String, dynamic> json) {return BannerResponse(errorCode: json['errorCode'] ?? 0,errorMsg: json['errorMsg'] ?? '',data: (json['data'] as List<dynamic>).map((e) => BannerItem.fromJson(e)).toList(),);}Map<String, dynamic> toJson() {return {'errorCode': errorCode,'errorMsg': errorMsg,'data': data.map((e) => e.toJson()).toList(),};}
}/// 单个 banner 项 (对应 data 中每一项)
class BannerItem {final String desc;final int id;final String imagePath;final int isVisible;final int order;final String title;final int type;final String url;BannerItem({required this.desc,required this.id,required this.imagePath,required this.isVisible,required this.order,required this.title,required this.type,required this.url,});factory BannerItem.fromJson(Map<String, dynamic> json) {return BannerItem(desc: json['desc'] ?? '',id: json['id'],imagePath: json['imagePath'] ?? '',isVisible: json['isVisible'] ?? 1,order: json['order'] ?? 0,title: json['title'] ?? '',type: json['type'] ?? 0,url: json['url'] ?? '',);}Map<String, dynamic> toJson() {return {'desc': desc,'id': id,'imagePath': imagePath,'isVisible': isVisible,'order': order,'title': title,'type': type,'url': url,};}
}

添加 models/articles_reponse.dart

class ArticleListResponse {final int errorCode;final String errorMsg;final ArticlePage data;ArticleListResponse({required this.errorCode,required this.errorMsg,required this.data,});factory ArticleListResponse.fromJson(Map<String, dynamic> json) {return ArticleListResponse(errorCode: json['errorCode'],errorMsg: json['errorMsg'],data: ArticlePage.fromJson(json['data']),);}Map<String, dynamic> toJson() {return {'errorCode': errorCode,'errorMsg': errorMsg,'data': data.toJson(),};}
}class ArticlePage {final int curPage;final List<Article> datas;final int offset;final bool over;final int pageCount;final int size;final int total;ArticlePage({required this.curPage,required this.datas,required this.offset,required this.over,required this.pageCount,required this.size,required this.total,});factory ArticlePage.fromJson(Map<String, dynamic> json) {return ArticlePage(curPage: json['curPage'],datas: (json['datas'] as List).map((e) => Article.fromJson(e)).toList(),offset: json['offset'],over: json['over'],pageCount: json['pageCount'],size: json['size'],total: json['total'],);}Map<String, dynamic> toJson() {return {'curPage': curPage,'datas': datas.map((e) => e.toJson()).toList(),'offset': offset,'over': over,'pageCount': pageCount,'size': size,'total': total,};}
}class Article with ChangeNotifier {final int id;final String title;final String link;final String niceDate;final String shareUser;final String author;final int zan;bool _collect;bool get collect => _collect;set collect(bool value) {_collect = value;notifyListeners();}final String chapterName;final String superChapterName;final int publishTime;final int type;Article({required this.id,required this.title,required this.link,required this.niceDate,required this.shareUser,required this.author,required this.zan,required bool collect,required this.chapterName,required this.superChapterName,required this.publishTime,required this.type,}) : _collect = collect;factory Article.fromJson(Map<String, dynamic> json) {return Article(id: json['id'] as int,title: json['title'] as String,link: json['link'] as String,niceDate: json['niceDate'] as String,shareUser: (json['shareUser'] ?? '') as String,author: (json['author'] ?? '') as String,zan: json['zan'] as int,collect: json['collect'] == true,chapterName: json['chapterName'] as String,superChapterName: json['superChapterName'] as String,publishTime: json['publishTime'] as int,type: json['type'] as int,);}Map<String, dynamic> toJson() {return {'id': id,'title': title,'link': link,'niceDate': niceDate,'shareUser': shareUser,'author': author,'zan': zan,'collect': collect,'chapterName': chapterName,'superChapterName': superChapterName,'publishTime': publishTime,'type': type,};}
}

扩充 services/api_service.dart

.
.
./// 首页 bannerstatic Future<Map<String, dynamic>> banner() async {final url = Uri.parse(ApiConstants.baseUrl + ApiConstants.banner);print('首页 banner url 为: $url');final response = await http.get(url, headers: headers);return _handleResponse(response);}/// 文章列表static Future<Map<String, dynamic>> articles(int page) async {final url = Uri.parse("${ApiConstants.baseUrl}${ApiConstants.homePageArticle}$page/json");print('请求文章列表 url 为: $url');final response = await http.get(url, headers: headers);return _handleResponse(response);}/// 收藏文章static Future<Map<String, dynamic>> collect(int id) async {final url = Uri.parse("${ApiConstants.baseUrl}${ApiConstants.collectArticle}$id/json");print('收藏文章 url 为: $url');final headers = <String, String>{};if (_cookie != null) {headers['Cookie'] = _cookie!;}final response = await http.post(url, headers: headers);return _handleResponse(response);}/// 取消收藏文章static Future<Map<String, dynamic>> uncollect(int id) async {final url = Uri.parse("${ApiConstants.baseUrl}${ApiConstants.uncollectArticel}$id/json");print('取消收藏文章 url 为: $url');final response = await http.post(url, headers: head
ers);return _handleResponse(response);}
.
.
.

添加 top/top_pager_view_model.dart

// 页面数据的 ViewModel,用于管理 Banner 和文章数据
class TopPagerViewModel extends ChangeNotifier {// Banner 数据列表(私有)List<BannerItem> _bannerList = [];// 对外暴露 Banner 数据List<BannerItem> get bannerList => _bannerList;// 文章列表数据(私有)List<Article> _articleList = [];// 对外暴露文章列表List<Article> get articleList => _articleList;// 加载 Banner 数据(调用 API,解析响应,更新状态)Future<void> loadBanner() async {// 调用 API 获取 banner 数据final response = await ApiService.banner();// 将响应数据转换为模型对象并赋值_bannerList = BannerResponse.fromJson(response).data;// 通知监听者(如 UI)更新notifyListeners();}// 加载文章数据,page 为页码(0 表示首页刷新)Future<void> loadArticles(int page) async {// 调用 API 获取文章列表数据final response = await ApiService.articles(page);// 解析响应数据中的文章列表final newList = ArticleListResponse.fromJson(response).data.datas;if (page == 0) {// 如果是第一页,重置整个文章列表_articleList = newList;} else {// 否则追加到原有列表后_articleList.addAll(newList);}// 通知 UI 更新notifyListeners();}// 收藏文章(传入文章 ID),返回是否成功Future<bool> collect(int id) async {final response = await ApiService.collect(id);final result = EmptyResponse.fromJson(response);return result.success;}// 取消收藏文章(传入文章 ID),返回是否成功Future<bool> uncollect(int id) async {final response = await ApiService.uncollect(id);final result = EmptyResponse.fromJson(response);return result.success;}}

现在我们还不能进行首页界面的设计,由于首页的内容是一个轮播图、一个文章列表,我们在 Android 中设计的时候,会在最外层加一个滑动控件,便于我们进行整体上下滑动,那么 Flutter 中是用什么组件呢?接下来我们先分析一下滚动视图组件 CustomScrollView

  • CustomScrollView 是一个可以自定义滚动效果的滚动视图组件。和 ListViewSingleChildScrollView 不同的是:

    它可以组合多个 Sliver(“可滚动片段”) 来形成一个完整的滚动区域。

  • Sliver 是 CustomScrollView 的“子组件”,全称是 Sliver Widgets,是 Flutter 为高性能滚动设计的一套 UI 架构。

    常见的 Sliver 类型

    Widget功能描述
    SliverAppBar可滚动的 AppBar
    SliverList可滚动的列表
    SliverGrid可滚动的网格布局
    SliverToBoxAdapter将普通组件适配为 Sliver(静态区域)
    SliverPadding给 Sliver 加内边距

    可以看到常见的 Sliver 中的 SliverToBoxAdapterSliverList 这两个 Widget,是不是组合在一起就很符合我们的界面设计

  • SliverToBoxAdapter 是用来 把普通 Widget 嵌入到 sliver 系统中的桥梁

    示例

    SliverToBoxAdapter(child: Container(height: 200,color: Colors.blue,child: Center(child: Text("我是 Banner")),),
    )
    
  • SliverListCustomScrollView 中用来展示 垂直滚动列表 的组件,和 ListView.builder 类似。

    示例

    SliverList(delegate: SliverChildBuilderDelegate((context, index) {return ListTile(title: Text("Item $index"));},childCount: 20,),
    )
    

    SliverChildBuilderDelegate 负责根据 index 构建子项,就像 ListView.builder

那么我们大致了解完了 CustomScrollView ,接下来就来实现首页界面的轮播图

top/top_page.dart 实现

// 顶部页面,使用 StatelessWidget 包装 ChangeNotifierProvider,提供 TopPagerViewModel
class TopPage extends StatelessWidget {const TopPage({super.key});Widget build(BuildContext context) {return ChangeNotifierProvider<TopPagerViewModel>(// 创建并提供 TopPagerViewModel 实例给子组件使用create: (_) => TopPagerViewModel(),child: _TopPageBody(),);}
}// 页面主体,负责显示 Banner 和文章列表,管理滚动与加载状态
class _TopPageBody extends StatefulWidget {State<_TopPageBody> createState() => _TopPageBodyState();
}class _TopPageBodyState extends State<_TopPageBody> with BasePage<_TopPageBody> {void initState() {super.initState();// 页面首次渲染完成后调用接口加载 Banner 和文章第一页数据WidgetsBinding.instance.addPostFrameCallback((_) {final vm = context.read<TopPagerViewModel>();vm.loadBanner();});}Widget build(BuildContext context) {// 监听 ViewModel 中的 Banner 和文章列表变化final bannerList = context.watch<TopPagerViewModel>().bannerList;return Container(color: Colors.white,child: CustomScrollView(controller: _scrollController,slivers: [// Banner 区域,使用 SliverToBoxAdapter 包装普通 WidgetSliverToBoxAdapter(child: VpBanner(radius: 10,margin: 3,height: 300,imageList: bannerList.map((e) => e.imagePath).toList(),indicatorType: IndicatorType.circle,bannerClick: (position) {ToastUtils.showToast(context, '点击了: ${bannerList[position].title}');}),),],),);}
}

运行效果如下所示

在这里插入图片描述

接下来我们继续实现文章列表,我们通过最初的 UI 知道,文章列表的每一个 Item 要显示文章作者、标题、tab类型、发布时间、是否进行了收藏,所以我们需要有这么一个 item widget,由于接口返回的数据中含有 Html 格式的数据

所以我们需要添加一个官方 Html 的标准库

pubspec.yaml

dependencies:flutter:sdk: flutterhttp: ^0.13.5provider: ^6.1.1flutter_html: ^3.0.0-beta.2

接着,我们添加 widgets/article_item.dart

class ArticleItemLayout extends StatefulWidget {// 构造函数,接收文章数据、收藏点击回调及是否显示收藏按钮const ArticleItemLayout({Key? key,required this.article,required this.onCollectTap,this.showCollectBtn}): super(key: key);final Article article; // 文章数据模型final void Function() onCollectTap; // 收藏按钮点击回调final bool? showCollectBtn; // 是否显示收藏按钮,默认为显示State<StatefulWidget> createState() => _ArticleItemState();
}class _ArticleItemState extends State<ArticleItemLayout> {void initState() {super.initState();// 监听文章收藏状态变化,更新UIwidget.article.addListener(_onCollectChange);}void didUpdateWidget(ArticleItemLayout oldWidget) {super.didUpdateWidget(oldWidget);// 如果传入的文章数据模型变化,重新注册监听if (oldWidget.article != widget.article) {oldWidget.article.removeListener(_onCollectChange);widget.article.addListener(_onCollectChange);}}void dispose() {super.dispose();// 移除监听,防止内存泄漏widget.article.removeListener(_onCollectChange);}// 文章收藏状态变更时调用,刷新当前组件_onCollectChange() {setState(() {});}Widget build(BuildContext context) {// 格式化发布时间字符串,去除末尾多余字符String publishTime =DateTime.fromMillisecondsSinceEpoch(widget.article.publishTime).toString();publishTime = publishTime.substring(0, publishTime.length - 4);// 拼接文章分类字符串,格式为 “父分类·子分类”StringBuffer sb = StringBuffer(widget.article.superChapterName ?? "");if (sb.isNotEmpty && widget.article.chapterName.isNotEmpty) {sb.write("·");}sb.write(widget.article.chapterName ?? "");return Container(padding: const EdgeInsets.symmetric(horizontal: 8),child: Card(surfaceTintColor: Colors.white,color: Colors.white,elevation: 8, // 卡片阴影child: Padding(padding: const EdgeInsets.all(8),child: Column(children: [Row(children: [// 如果文章是置顶,显示“置顶”红色标签if (widget.article.type == 1)const Padding(padding: EdgeInsets.only(left: 8),child: Text("置顶",style: TextStyle(color: Colors.red),)),// 作者名称,若作者为空显示分享人Container(padding: widget.article.type == 1? const EdgeInsets.fromLTRB(8, 0, 0, 0): const EdgeInsets.fromLTRB(12, 0, 0, 0),child: Text('作者: ${widget.article.author.isNotEmpty == true ? widget.article.author : widget.article.shareUser ?? ""}'),),Expanded(child: Container(padding: const EdgeInsets.only(right: 8),alignment: Alignment.centerRight,// 发布时间显示child: Text(publishTime),),)],),// 文章标题区域,支持HTML样式并限制最大两行,溢出省略Container(padding: const EdgeInsets.fromLTRB(10, 8, 8, 8),child: Row(children: [Expanded(child: Html(data: widget.article.title,style: {"html": Style(margin: Margins.zero,maxLines: 2,textOverflow: TextOverflow.ellipsis,fontSize: FontSize(14),padding: HtmlPaddings.zero,alignment: Alignment.topLeft),"body": Style(margin: Margins.zero,maxLines: 2,textOverflow: TextOverflow.ellipsis,fontSize: FontSize(14),padding: HtmlPaddings.zero,alignment: Alignment.topLeft)},))],),),// 分类标签与收藏按钮一行布局Row(children: [// 显示文章分类Padding(padding: const EdgeInsets.only(left: 10),child: Text(sb.toString())),Expanded(child: Container(width: 24,height: 24,alignment: Alignment.topRight,padding: const EdgeInsets.only(right: 8),child: Builder(builder: (context) {// 如果不显示收藏按钮,返回空容器if (widget.showCollectBtn == false) {return Container();}// 收藏按钮,已收藏为红色实心心形,未收藏为默认图标return GestureDetector(onTap: widget.onCollectTap,child: !(widget.article.collect)? Icon(Icons.favorite): Icon(Icons.favorite, color: Colors.red),);},),),),],)],),),));}
}

接着我们添加到 SliverList

扩充 top/top_page.dart 我们要完成列表的显示、列表的加载更多、文章的收藏和取消收藏

// 顶部页面,使用 StatelessWidget 包装 ChangeNotifierProvider,提供 TopPagerViewModel
class TopPage extends StatelessWidget {const TopPage({super.key});Widget build(BuildContext context) {return ChangeNotifierProvider<TopPagerViewModel>(// 创建并提供 TopPagerViewModel 实例给子组件使用create: (_) => TopPagerViewModel(),child: _TopPageBody(),);}
}// 页面主体,负责显示 Banner 和文章列表,管理滚动与加载状态
class _TopPageBody extends StatefulWidget {State<_TopPageBody> createState() => _TopPageBodyState();
}class _TopPageBodyState extends State<_TopPageBody> with BasePage<_TopPageBody> {late final ScrollController _scrollController; // 滚动控制器,用于监听滚动事件int _currentPage = 0; // 当前文章页码bool _isLoadingMore = false; // 是否正在加载更多void initState() {super.initState();// 初始化滚动控制器并监听滚动事件_scrollController = ScrollController();_scrollController.addListener(_onScroll);// 页面首次渲染完成后调用接口加载 Banner 和文章第一页数据WidgetsBinding.instance.addPostFrameCallback((_) {final vm = context.read<TopPagerViewModel>();vm.loadBanner();vm.loadArticles(0);});}void dispose() {// 释放滚动控制器资源_scrollController.dispose();super.dispose();}Widget build(BuildContext context) {// 监听 ViewModel 中的 Banner 和文章列表变化final bannerList = context.watch<TopPagerViewModel>().bannerList;final articleList = context.watch<TopPagerViewModel>().articleList;return Container(color: Colors.white,child: CustomScrollView(controller: _scrollController,slivers: [// Banner 区域,使用 SliverToBoxAdapter 包装普通 WidgetSliverToBoxAdapter(child: VpBanner(radius: 10,margin: 3,height: 300,imageList: bannerList.map((e) => e.imagePath).toList(),indicatorType: IndicatorType.circle,bannerClick: (position) {ToastUtils.showToast(context, '点击了: ${bannerList[position].title}');}),),SliverList(// 使用 SliverChildBuilderDelegate 动态构建列表项delegate: SliverChildBuilderDelegate((context, index) {// 获取对应索引的文章数据final article = articleList[index];print('$index article 的 collect 为: ${article.collect}');return GestureDetector(// 点击整条文章项的回调(这里留空,可自定义)onTap: () {},child: ArticleItemLayout(article: article,// 点击收藏按钮时调用的回调,触发收藏/取消收藏逻辑onCollectTap: () {_onCollectClick(article);},),);},// 列表项数量childCount: articleList.length,),),],),);}// 文章收藏/取消收藏事件处理_onCollectClick(Article article) async {bool collected = article.collect;// 根据当前收藏状态调用收藏或取消收藏接口bool isSuccess = await (!collected? context.read<TopPagerViewModel>().collect(article.id): context.read<TopPagerViewModel>().uncollect(article.id));if (isSuccess) {ToastUtils.showToast(context, collected ? "取消收藏!" : "收藏成功!");article.collect = !article.collect; // 更新收藏状态} else {ToastUtils.showToast(context, collected ? "取消收藏失败 -- " : "收藏失败 -- ");}}// 滚动监听,判断是否需要加载更多数据void _onScroll() {final maxScroll = _scrollController.position.maxScrollExtent;final currentScroll = _scrollController.position.pixels;// 当滚动到接近底部且非加载状态,触发加载更多if (currentScroll >= maxScroll - 50 && !_isLoadingMore) {_loadMore();}}/// 加载更多文章数据Future<void> _loadMore() async {_isLoadingMore = true;_currentPage++; // 页码加1await context.read<TopPagerViewModel>().loadArticles(_currentPage);_isLoadingMore = false;}
}

简简单单,运行效果已经和最初我们要设计的界面一模一样了,不过有个问题,当我们点击收藏和取消收藏的时候,好像有个bug,就是我们只能做到本地的收藏和取消收藏,当我们点击收藏或取消收藏的时候,我们在网页打开玩安卓界面,登录相同账号,查看首页的收藏状态,记得要退出登录后在登录查看,否则有缓存会导致数据不及时,我们发现,我们在App中进行收藏和取消收藏,网页并没有任何改动,这就很奇怪,我们分析发现,这个接口就是普通的post,而且官方的API文档中说的是

在这里插入图片描述

在这里插入图片描述

这个 id 指的是文章 ID,和用户好像没啥关联,那么这个时候,我们就可以排除是接口的问题,而是我们 网络请求的处理有问题,我们都知道登录后服务器发给客户端cookie,客户端后续请求必须携带此cookie,服务器才能识别用户身份,实现登录状态和用户数据的关联。 如果不带cookie,服务器不知道是谁发出的请求,数据自然无法与用户关联。所以,我们将登录获取到的 cookie 添加至之后的网络请求中

修改 services/api_service.dart

class ApiService {static String? _cookie; // 内存保存Cookie/// 登录static Future<Map<String, dynamic>> login({required String username,required String password,}) async {final url = Uri.parse(ApiConstants.baseUrl + ApiConstants.login);final response = await http.post(url,headers: {'Content-Type': 'application/x-www-form-urlencoded'},body: {'username': username,'password': password,},);// 获取响应头 Set-Cookiefinal rawCookie = response.headers['set-cookie'];if (rawCookie != null) {_cookie = _parseCookie(rawCookie);print('保存的 cookie: $_cookie}');}return _handleResponse(response);}/// 注册static Future<Map<String, dynamic>> register({required String username,required String password,required String repassword,}) async {final url = Uri.parse(ApiConstants.baseUrl + ApiConstants.register);final response = await http.post(url,headers: {'Content-Type': 'application/x-www-form-urlencoded'},body: {'username': username,'password': password,'repassword': repassword,},);return _handleResponse(response);}/// 首页 bannerstatic Future<Map<String, dynamic>> banner() async {final url = Uri.parse(ApiConstants.baseUrl + ApiConstants.banner);print('首页 banner url 为: $url');final headers = <String, String>{};if (_cookie != null) {headers['Cookie'] = _cookie!;}final response = await http.get(url, headers: headers);return _handleResponse(response);}/// 文章列表static Future<Map<String, dynamic>> articles(int page) async {final url = Uri.parse("${ApiConstants.baseUrl}${ApiConstants.homePageArticle}$page/json");print('请求文章列表 url 为: $url');final headers = <String, String>{};if (_cookie != null) {headers['Cookie'] = _cookie!;}final response = await http.get(url, headers: headers);return _handleResponse(response);}/// 收藏文章static Future<Map<String, dynamic>> collect(int id) async {final url = Uri.parse("${ApiConstants.baseUrl}${ApiConstants.collectArticle}$id/json");print('收藏文章 url 为: $url');final headers = <String, String>{};if (_cookie != null) {headers['Cookie'] = _cookie!;}final response = await http.post(url, headers: headers);return _handleResponse(response);}/// 取消收藏文章static Future<Map<String, dynamic>> uncollect(int id) async {final url = Uri.parse("${ApiConstants.baseUrl}${ApiConstants.uncollectArticel}$id/json");print('取消收藏文章 url 为: $url');final headers = <String, String>{};if (_cookie != null) {headers['Cookie'] = _cookie!;}final response = await http.post(url, headers: headers);return _handleResponse(response);}/// 通用处理响应static Map<String, dynamic> _handleResponse(http.Response response) {if (response.statusCode == 200) {print("请求结果为: ${response.body}");return jsonDecode(response.body);} else {throw Exception('请求失败:${response.statusCode}');}}/// 解析cookie,去掉后面的属性,只保留key=value部分static String _parseCookie(String rawCookie) {// rawCookie格式可能是: "JSESSIONID=xxxx; Path=/; HttpOnly"return rawCookie.split(';').first;}
}

然后我们进行重新编译运行,然后在进行收藏与取消收藏操作,发现没问题了,至此我们首页模块的设计就完成了,文章详情、搜索界面在之后会逐一实现,接下来我们去实现体系界面的设计

二、体系界面

ok,接下来我们继续实现体系界面的设计,UI如下所示

在这里插入图片描述
在这里插入图片描述

通过观察,我们不难发现,界面就是一个可扩展的垂直列表,并且扩展的内容也是垂直的,可以点击进入查看对应体系的文章列表,体系文章列表界面呢又是顶部一个滑动导航加一个垂直文章列表,垂直文章列表我们可以复用首页的 ArticleItemLayout

2.1 体系扩展列表

文章列表、收藏相关的数据类可以复用,所以,这里只用新增体系数据的数据类。

models/chapter_response.dart

class ChapterResponse {List<Chapter> data;int errorCode;String errorMsg;ChapterResponse({required this.data,required this.errorCode,required this.errorMsg,});factory ChapterResponse.fromJson(Map<String, dynamic> json) {return ChapterResponse(data: (json['data'] as List).map((e) => Chapter.fromJson(e)).toList(),errorCode: json['errorCode'],errorMsg: json['errorMsg'],);}Map<String, dynamic> toJson() {return {'data': data.map((e) => e.toJson()).toList(),'errorCode': errorCode,'errorMsg': errorMsg,};}
}class Chapter {List<dynamic> articleList;String author;List<Chapter> children;int courseId;String cover;String desc;int id;String lisense;String lisenseLink;String name;int order;int parentChapterId;int type;bool userControlSetTop;int visible;Chapter({required this.articleList,required this.author,required this.children,required this.courseId,required this.cover,required this.desc,required this.id,required this.lisense,required this.lisenseLink,required this.name,required this.order,required this.parentChapterId,required this.type,required this.userControlSetTop,required this.visible,});factory Chapter.fromJson(Map<String, dynamic> json) {return Chapter(articleList: json['articleList'] ?? [],author: json['author'] ?? '',children:(json['children'] as List).map((e) => Chapter.fromJson(e)).toList(),courseId: json['courseId'],cover: json['cover'] ?? '',desc: json['desc'] ?? '',id: json['id'],lisense: json['lisense'] ?? '',lisenseLink: json['lisenseLink'] ?? '',name: json['name'] ?? '',order: json['order'],parentChapterId: json['parentChapterId'],type: json['type'],userControlSetTop: json['userControlSetTop'] ?? false,visible: json['visible'],);}Map<String, dynamic> toJson() {return {'articleList': articleList,'author': author,'children': children.map((e) => e.toJson()).toList(),'courseId': courseId,'cover': cover,'desc': desc,'id': id,'lisense': lisense,'lisenseLink': lisenseLink,'name': name,'order': order,'parentChapterId': parentChapterId,'type': type,'userControlSetTop': userControlSetTop,'visible': visible,};}
}

添加接口services/api_services.dart

.
.
.
/// 获取体系列表数据static Future<Map<String, dynamic>> loadTreeList() async {final url = Uri.parse("${ApiConstants.baseUrl}${ApiConstants.tree}");print('体系列表数据 url 为: $url');final headers = <String, String>{};if (_cookie != null) {headers['Cookie'] = _cookie!;}final response = await http.get(url, headers: headers);return _handleResponse(response);}/// 获取体系下的文章static Future<Map<String, dynamic>> loadTreeChildList(int page, int cid) async {final url = Uri.parse("${ApiConstants.baseUrl}${ApiConstants.homePageArticle}$page/json?cid=$cid");print('获取体系下的文章 url 为: $url');final headers = <String, String>{};if (_cookie != null) {headers['Cookie'] = _cookie!;}final response = await http.get(url, headers: headers);return _handleResponse(response);}
.
.
.

添加 providers/tree_response_provider.dart

// TreeResponseProvider 是一个全局数据提供器,用于保存“体系结构(章节)”列表
// 它继承自 ChangeNotifier,可通过 Provider 通知 UI 更新
class TreeResponseProvider extends ChangeNotifier {// 私有成员变量,存储体系结构数据(章节列表)List<Chapter>? _chapterList;// 对外暴露只读属性,用于获取章节列表List<Chapter>? get chapterList => _chapterList;// 保存章节数据并通知监听者(如 UI 刷新)void saveChapter(List<Chapter> chapterList) {_chapterList = chapterList;notifyListeners(); // 通知依赖此数据的组件重新构建}// 登出时清空章节数据并通知监听者void logOut() {_chapterList = null;notifyListeners(); // 通知 UI 数据已清空(可用于重定向或重置)}
}

记得在 main.dart 启动中进行注册哦

添加 ViewModel

tree/tree__page__view_model.dart

class TreePageViewModel extends ChangeNotifier {List<Chapter> _chapterList = [];List<Chapter> get chapterList => _chapterList;/// 获取体系数据Future<void> loadTree() async {final response = await ApiService.loadTreeList();_chapterList = ChapterResponse.fromJson(response).data;notifyListeners();}
}

咱们这里不打算使用第三库组件来实现垂直列表+可扩展列表,这里咱们使用 ListView+ExpansionTile来实现我们想要的效果

tree/tree_page.dart

class TreePage extends StatelessWidget {const TreePage({super.key});Widget build(BuildContext context) {return ChangeNotifierProvider<TreePageViewModel>(create: (_) => TreePageViewModel(), // 创建 ViewModelchild: _TreePageBody(), // 子组件主体);}
}// TreePage 的页面主体,使用 StatefulWidget 方便进行生命周期控制
class _TreePageBody extends StatefulWidget {_TreePageState createState() => _TreePageState();
}class _TreePageState extends State<_TreePageBody> with BasePage<_TreePageBody> {void initState() {super.initState();// 页面初始化后加载体系结构数据WidgetsBinding.instance.addPostFrameCallback((_) {final vm = context.read<TreePageViewModel>();vm.loadTree(); // 加载章节树数据});}Widget build(BuildContext context) {// 从 ViewModel 获取体系结构列表(chapterList)final chapterList = context.watch<TreePageViewModel>().chapterList;// 保存章节数据到全局 provider(用于子页面访问)context.read<TreeResponseProvider>().saveChapter(chapterList);// 记录当前展开的章节 id(防止切换状态时丢失展开状态)final Set<int> _expandedIds = {};return Container(color: Colors.white,child: ListView.builder(itemCount: chapterList.length, // 章节数量itemBuilder: (context, index) {final chapter = chapterList[index]; // 当前章节return Card(margin: const EdgeInsets.all(8), // 每个章节用卡片包裹child: ExpansionTile(title: Text(chapter.name), // 展示章节标题initiallyExpanded: _expandedIds.contains(chapter.id), // 判断是否默认展开onExpansionChanged: (isExpanded) {setState(() {// 展开或折叠时记录当前章节 id 到集合中if (isExpanded) {_expandedIds.add(chapter.id);} else {_expandedIds.remove(chapter.id);}});},// 显示子分类列表(每个子项是一个 ListTile)children: chapter.children.map((child) {return Container(margin: EdgeInsets.only(left: 24), // 子项左缩进decoration: BoxDecoration(border: Border(left: BorderSide(color: Colors.grey[300]!, width: 2), // 左侧竖线),),child: ListTile(title: Text(child.name),onTap: () => {// 弹出 toast 显示点击信息ToastUtils.showToast(context, "点击子项: ${child.name} (ID: ${child.id})")},),);}).toList(),),);},),);}
}

这样我们就实现了体系界面的效果

接下来我们要实现体系下的详情列表界面,也就是上面的图二

我们创建 treechild/tree_page_child_view_model.dart

class TreePageChildViewModel extends ChangeNotifier {// 文章列表数据(私有)List<Article> _articleList = [];// 对外暴露文章列表List<Article> get articleList => _articleList;// 加载文章数据,page 为页码(0 表示首页刷新)Future<void> loadArticles(int page, int cid) async {// 调用 API 获取文章列表数据final response = await ApiService.loadTreeChildList(page, cid);// 解析响应数据中的文章列表final newList = ArticleListResponse.fromJson(response).data.datas;if (page == 0) {// 如果是第一页,重置整个文章列表_articleList = newList;} else {// 否则追加到原有列表后_articleList.addAll(newList);}// 通知 UI 更新notifyListeners();}// 收藏文章(传入文章 ID),返回是否成功Future<bool> collect(int id) async {final response = await ApiService.collect(id);final result = EmptyResponse.fromJson(response);return result.success;}// 取消收藏文章(传入文章 ID),返回是否成功Future<bool> uncollect(int id) async {final response = await ApiService.uncollect(id);final result = EmptyResponse.fromJson(response);return result.success;}
}

通过对UI的观察我们可以发现,界面顶部就只有一个返回按钮和体系类型标题,内容是一个顶部导航水平列表和一个文章垂直列表界面,这里我们可以服用之前抽离的 ArticleItemLayout 组件来实现

treechild/tree_page_child_view_model.dart

class TreePageChild extends StatelessWidget {final int chapterId; // 父分类索引(在全局章节列表中的位置)final int childId; // 子分类 ID(真正用于加载文章)const TreePageChild({Key? key,required this.chapterId,required this.childId,}) : super(key: key);Widget build(BuildContext context) {// 向下提供 ViewModel,并将 chapterId 和 childId 传入子组件return ChangeNotifierProvider<TreePageChildViewModel>(create: (_) => TreePageChildViewModel(),child: _TreePageChildBody(chapterId: chapterId,childId: childId,),);}
}// 页面主体(StatefulWidget):接收父分类索引和子分类 ID,用于展示 Tab + 文章列表
class _TreePageChildBody extends StatefulWidget {final int chapterId;final int childId;const _TreePageChildBody({super.key,required this.chapterId,required this.childId,});_TreePageChildState createState() => _TreePageChildState();
}class _TreePageChildState extends State<_TreePageChildBody>with BasePage<_TreePageChildBody>, SingleTickerProviderStateMixin {// 控制 TabBar 与 TabBarView 联动的控制器late TabController _tabController;void initState() {super.initState();// 从全局 Provider 获取章节数据final chapterList = context.read<TreeResponseProvider>().chapterList!;final children = chapterList[widget.chapterId].children;// 查找当前 childId 在 children 中的 index,作为默认选中的 Tabfinal initialIndex = children.indexWhere((c) => c.id == widget.childId);// 初始化 TabController_tabController = TabController(length: children.length,vsync: this,initialIndex: initialIndex,);// 页面绘制后,立即加载默认选中的 Tab 对应的文章列表WidgetsBinding.instance.addPostFrameCallback((_) {_loadArticlesAtIndex(_tabController.index);});// 监听 tab 切换事件_tabController.addListener(() {// index 发生变化且切换完成时触发if (_tabController.indexIsChanging == false) {_loadArticlesAtIndex(_tabController.index);}});}// 加载某个 tab 对应子分类的文章列表void _loadArticlesAtIndex(int index) {final chapterList = context.read<TreeResponseProvider>().chapterList!;final childChapter = chapterList[widget.chapterId].children[index];// 调用 ViewModel 加载指定分类文章context.read<TreePageChildViewModel>().loadArticles(0, childChapter.id);}void dispose() {_tabController.dispose(); // 销毁 TabControllersuper.dispose();}Widget build(BuildContext context) {final chapterList = context.read<TreeResponseProvider>().chapterList!;final children = chapterList[widget.chapterId].children;return DefaultTabController(length: children.length,child: Scaffold(appBar: AppBar(// 标题为当前父分类名称title: Text(chapterList[widget.chapterId].name),bottom: TabBar(controller: _tabController,isScrollable: true,tabs: children.map((c) => Tab(text: c.name)).toList(), // 子分类名作为 Tab),),body: TabBarView(controller: _tabController,children: List.generate(children.length, (index) {return Consumer<TreePageChildViewModel>(builder: (_, vm, __) {final articles = vm.articleList;// 加载中或数据为空时显示加载指示器if (articles.isEmpty) {return const Center(child: CircularProgressIndicator());}// 显示文章列表return ListView.builder(itemCount: articles.length,itemBuilder: (_, i) {return ArticleItemLayout(article: articles[i], // 单篇文章数据onCollectTap: () {_onCollectClick(articles[i]); // 收藏按钮点击事件},showCollectBtn: true,);},);},);}),),),);}// 处理文章的收藏/取消收藏点击事件_onCollectClick(Article article) async {bool collected = article.collect;// 根据当前状态选择调用收藏或取消收藏接口bool isSuccess = await (!collected? context.read<TreePageChildViewModel>().collect(article.id): context.read<TreePageChildViewModel>().uncollect(article.id));if (isSuccess) {// 收藏/取消成功:提示 + 刷新 UIToastUtils.showToast(context, collected ? "取消收藏!" : "收藏成功!");article.collect = !article.collect;} else {// 收藏/取消失败:提示ToastUtils.showToast(context, collected ? "取消收藏失败 -- " : "收藏失败 -- ");}}
}

记得我们要完成页面的跳转和路由表的注册

app/constants.dart

class RoutesConstants {/// 登录注册界面static const String login = "/login_register";/// 首页static const String home = "/home";/// 体系文章列表static const String treeChild = "/tree_child";
}

app/routes.dart

/// 路由表及跳转管理
class AppRoutes{static final routes = <String, WidgetBuilder>{RoutesConstants.login: (_) => LoginRegisterPage(),RoutesConstants.home: (_) => HomePage(),RoutesConstants.treeChild: (context) {// 从ModalRoute获取参数final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;return TreePageChild(chapterId: args['chapterId'],childId: args['childId'],);},};
}

tree/tree_page.dart 修改原先点击Toast弹出显示子项名和ID的代码

// 点击跳转到 TreePageChild 页面Navigator.pushNamed(context,RoutesConstants.treeChild,arguments: {"chapterId": index, // 父分类索引'childId': child.id, // 子分类 ID},)

至此我们完成了体系界面的设计

3. 导航界面

接下来我们来实现导航界面,导航界面的设计就更简单了,如图所示

在这里插入图片描述

不难发现,这个界面的的设计很简单,就是一个左侧垂直导航加一个右侧滑动瀑布流列表布局,接下来我们将逐步实现

app/constants.dart

.
.
.
/// 导航static const String navi = "navi/json";
.
.
.

models/navi_response.dart

class NaviResponse {final List<NaviData> data;final int errorCode;final String errorMsg;NaviResponse({required this.data,required this.errorCode,required this.errorMsg,});factory NaviResponse.fromJson(Map<String, dynamic> json) {return NaviResponse(data: (json['data'] as List<dynamic>).map((e) => NaviData.fromJson(e)).toList(),errorCode: json['errorCode'] as int,errorMsg: json['errorMsg'] as String,);}Map<String, dynamic> toJson() => {'data': data.map((e) => e.toJson()).toList(),'errorCode': errorCode,'errorMsg': errorMsg,};
}class NaviData {final int cid;final String name;final List<NaviArticle> articles;NaviData({required this.cid,required this.name,required this.articles,});factory NaviData.fromJson(Map<String, dynamic> json) {return NaviData(cid: json['cid'],name: json['name'],articles: (json['articles'] as List<dynamic>).map((e) => NaviArticle.fromJson(e)).toList(),);}Map<String, dynamic> toJson() => {'cid': cid,'name': name,'articles': articles.map((e) => e.toJson()).toList(),};
}class NaviArticle extends ChangeNotifier {final int id;final String title;final String author;final String link;final int chapterId;final String chapterName;final String superChapterName;final int superChapterId;final bool collect;final int publishTime;final String niceDate;final String niceShareDate;final String shareUser;bool isAdminAdd;final int zan;final int userId;final bool fresh;final String envelopePic;NaviArticle({required this.id,required this.title,required this.author,required this.link,required this.chapterId,required this.chapterName,required this.superChapterName,required this.superChapterId,required this.collect,required this.publishTime,required this.niceDate,required this.niceShareDate,required this.shareUser,required this.isAdminAdd,required this.zan,required this.userId,required this.fresh,required this.envelopePic,});factory NaviArticle.fromJson(Map<String, dynamic> json) {return NaviArticle(id: json['id'],title: json['title'],author: json['author'],link: json['link'],chapterId: json['chapterId'],chapterName: json['chapterName'],superChapterName: json['superChapterName'],superChapterId: json['superChapterId'],collect: json['collect'],publishTime: json['publishTime'],niceDate: json['niceDate'],niceShareDate: json['niceShareDate'],shareUser: json['shareUser'],isAdminAdd: json['isAdminAdd'],zan: json['zan'],userId: json['userId'],fresh: json['fresh'],envelopePic: json['envelopePic'],);}Map<String, dynamic> toJson() => {'id': id,'title': title,'author': author,'link': link,'chapterId': chapterId,'chapterName': chapterName,'superChapterName': superChapterName,'superChapterId': superChapterId,'collect': collect,'publishTime': publishTime,'niceDate': niceDate,'niceShareDate': niceShareDate,'shareUser': shareUser,'isAdminAdd': isAdminAdd,'zan': zan,'userId': userId,'fresh': fresh,'envelopePic': envelopePic,};
}

我们添加官方提供的瀑布流库

pubspec.yaml

flutter_staggered_grid_view: ^0.7.0

添加导航界面的 ViewModel

navi/navi_page_view_model.dart

class NaviPageViewModel extends ChangeNotifier {List<NaviData> _naviData = [];List<NaviData> get naviData => _naviData;/// 获取导航数据Future<void> loadNavi() async {final response = await ApiService.loadNaviData();_naviData = NaviResponse.fromJson(response).data;notifyListeners();}
}

navi/navi_page.dart

class NaviPage extends StatelessWidget {const NaviPage({super.key});Widget build(BuildContext context) {return ChangeNotifierProvider<NaviPageViewModel>(create: (_) => NaviPageViewModel(), // 创建 ViewModelchild: _NaviPageBody(), // 子组件主体);}
}class _NaviPageBody extends StatefulWidget {_NaviPageState createState() => _NaviPageState();
}class _NaviPageState extends State<_NaviPageBody> with BasePage<_NaviPageBody> {int selectedIndex = 0;void initState() {super.initState();WidgetsBinding.instance.addPostFrameCallback((_) {final vm = context.read<NaviPageViewModel>();vm.loadNavi(); });}Widget build(BuildContext context) {final naviDataList = context.watch<NaviPageViewModel>().naviData;print('获取到导航数据: ${naviDataList.length}');return Scaffold(body: Row(crossAxisAlignment: CrossAxisAlignment.start,children: [/// 左侧导航栏Container(width: 100,color: Colors.grey[200],child: ListView.builder(itemCount: naviDataList.length,itemBuilder: (_, index) {final selected = index == selectedIndex;return GestureDetector(onTap: () => setState(() => selectedIndex = index),child: Container(padding: const EdgeInsets.symmetric(vertical: 16),color: selected ? Colors.white : Colors.transparent,child: Center(child: Text(naviDataList[index].name,style: TextStyle(fontWeight:selected ? FontWeight.bold : FontWeight.normal,color: selected ? Colors.blue : Colors.black87,),),),),);},),),/// 右侧内容(瀑布流)Expanded(child: Padding(padding: const EdgeInsets.all(8),child: MasonryGridView.count(crossAxisCount: 2,mainAxisSpacing: 8,crossAxisSpacing: 8,itemCount: naviDataList[selectedIndex].articles.length,itemBuilder: (context, index) {final article = naviDataList[selectedIndex].articles[index];return Container(padding: const EdgeInsets.all(8),decoration: BoxDecoration(color: Colors.blue[100 * ((index % 8) + 1)],borderRadius: BorderRadius.circular(12),),child: Column(crossAxisAlignment: CrossAxisAlignment.start,children: [Text(article.title,style: const TextStyle(color: Colors.white,fontSize: 16,),),],),);},),),),],),);}
}

至此我们完成了导航界面的设计,应该没啥难度把,就多了一个瀑布流相关布局的组件,作者这里就简单讲一下

瀑布流是一种非均匀高度、多列排列的列表布局,常用于展示卡片、图片、商品等内容,例如 Pinterest 的布局。它的特点是:

  • 多列排列(例如 2列或4列)

  • 每列内容 高度不一

  • 自动补齐,按列填充内容

  • 可滚动

简单示例

MasonryGridView.count(crossAxisCount: 2, // 列数mainAxisSpacing: 8, // 主轴间距(上下)crossAxisSpacing: 8, // 交叉轴间距(左右)itemCount: list.length,itemBuilder: (context, index) {return YourItemWidget(list[index]);},
)

4. 项目界面

接下来,我们继续实现项目界面的UI设计

在这里插入图片描述

通过观察UI图,我们可以发现,只需要完成一个顶部导航栏和一个垂直列表,垂直列表显示图片、标题、描述、作者、时间,以及加载更多列表的功能

首先我们添加接口相关

services/api_services.dart

/// 获取项目分类数据static Future<Map<String, dynamic>> loadProjectCategory() async {final url =Uri.parse("${ApiConstants.baseUrl}${ApiConstants.projectCategory}");print('获取导航数据 url 为: $url');final headers = <String, String>{};if (_cookie != null) {headers['Cookie'] = _cookie!;}final response = await http.get(url, headers: headers);return _handleResponse(response);}/// 获取项目文章列表数据static Future<Map<String, dynamic>> loadProjectList(int page, int cid) async {final url = Uri.parse("${ApiConstants.baseUrl}${ApiConstants.projectList}$page/json?cid=$cid");print('获取体系下的文章 url 为: $url');final headers = <String, String>{};if (_cookie != null) {headers['Cookie'] = _cookie!;}final response = await http.get(url, headers: headers);return _handleResponse(response);}

数据类相关

models/project_article_response.dart

class ProjectArticleResponse {final ProjectArticleData data;final int errorCode;final String errorMsg;ProjectArticleResponse({required this.data,required this.errorCode,required this.errorMsg,});factory ProjectArticleResponse.fromJson(Map<String, dynamic> json) {return ProjectArticleResponse(data: ProjectArticleData.fromJson(json['data']),errorCode: json['errorCode'],errorMsg: json['errorMsg'],);}Map<String, dynamic> toJson() => {'data': data.toJson(),'errorCode': errorCode,'errorMsg': errorMsg,};
}class ProjectArticleData {final int curPage;final List<ProjectArticle> datas;final int offset;final bool over;final int pageCount;final int size;final int total;ProjectArticleData({required this.curPage,required this.datas,required this.offset,required this.over,required this.pageCount,required this.size,required this.total,});factory ProjectArticleData.fromJson(Map<String, dynamic> json) {return ProjectArticleData(curPage: json['curPage'],datas: (json['datas'] as List).map((e) => ProjectArticle.fromJson(e)).toList(),offset: json['offset'],over: json['over'],pageCount: json['pageCount'],size: json['size'],total: json['total'],);}Map<String, dynamic> toJson() => {'curPage': curPage,'datas': datas.map((e) => e.toJson()).toList(),'offset': offset,'over': over,'pageCount': pageCount,'size': size,'total': total,};
}class ProjectArticle {final bool adminAdd;final String apkLink;final int audit;final String author;final bool canEdit;final int chapterId;final String chapterName;bool collect;final int courseId;final String desc;final String descMd;final String envelopePic;final bool fresh;final String host;final int id;final bool isAdminAdd;final String link;final String niceDate;final String niceShareDate;final String origin;final String prefix;final String projectLink;final int publishTime;final int realSuperChapterId;final int selfVisible;final int? shareDate;final String shareUser;final int superChapterId;final String superChapterName;final List<ProjectTag> tags;final String title;final int type;final int userId;final int visible;final int zan;ProjectArticle({required this.adminAdd,required this.apkLink,required this.audit,required this.author,required this.canEdit,required this.chapterId,required this.chapterName,required this.collect,required this.courseId,required this.desc,required this.descMd,required this.envelopePic,required this.fresh,required this.host,required this.id,required this.isAdminAdd,required this.link,required this.niceDate,required this.niceShareDate,required this.origin,required this.prefix,required this.projectLink,required this.publishTime,required this.realSuperChapterId,required this.selfVisible,required this.shareDate,required this.shareUser,required this.superChapterId,required this.superChapterName,required this.tags,required this.title,required this.type,required this.userId,required this.visible,required this.zan,});factory ProjectArticle.fromJson(Map<String, dynamic> json) {return ProjectArticle(adminAdd: json['adminAdd'],apkLink: json['apkLink'],audit: json['audit'],author: json['author'],canEdit: json['canEdit'],chapterId: json['chapterId'],chapterName: json['chapterName'],collect: json['collect'],courseId: json['courseId'],desc: json['desc'],descMd: json['descMd'],envelopePic: json['envelopePic'],fresh: json['fresh'],host: json['host'],id: json['id'],isAdminAdd: json['isAdminAdd'],link: json['link'],niceDate: json['niceDate'],niceShareDate: json['niceShareDate'],origin: json['origin'],prefix: json['prefix'],projectLink: json['projectLink'],publishTime: json['publishTime'],realSuperChapterId: json['realSuperChapterId'],selfVisible: json['selfVisible'],shareDate: json['shareDate'],shareUser: json['shareUser'],superChapterId: json['superChapterId'],superChapterName: json['superChapterName'],tags: (json['tags'] as List).map((e) => ProjectTag.fromJson(e)).toList(),title: json['title'],type: json['type'],userId: json['userId'],visible: json['visible'],zan: json['zan'],);}Map<String, dynamic> toJson() => {'adminAdd': adminAdd,'apkLink': apkLink,'audit': audit,'author': author,'canEdit': canEdit,'chapterId': chapterId,'chapterName': chapterName,'collect': collect,'courseId': courseId,'desc': desc,'descMd': descMd,'envelopePic': envelopePic,'fresh': fresh,'host': host,'id': id,'isAdminAdd': isAdminAdd,'link': link,'niceDate': niceDate,'niceShareDate': niceShareDate,'origin': origin,'prefix': prefix,'projectLink': projectLink,'publishTime': publishTime,'realSuperChapterId': realSuperChapterId,'selfVisible': selfVisible,'shareDate': shareDate,'shareUser': shareUser,'superChapterId': superChapterId,'superChapterName': superChapterName,'tags': tags.map((e) => e.toJson()).toList(),'title': title,'type': type,'userId': userId,'visible': visible,'zan': zan,};
}class ProjectTag {final String name;final String url;ProjectTag({required this.name,required this.url,});factory ProjectTag.fromJson(Map<String, dynamic> json) {return ProjectTag(name: json['name'],url: json['url'],);}Map<String, dynamic> toJson() => {'name': name,'url': url,};
}

projcet_category_response.dart

class ProjectCategoryResponse {final List<ProjectCategory> data;final int errorCode;final String errorMsg;ProjectCategoryResponse({required this.data,required this.errorCode,required this.errorMsg,});factory ProjectCategoryResponse.fromJson(Map<String, dynamic> json) {return ProjectCategoryResponse(data: (json['data'] as List).map((e) => ProjectCategory.fromJson(e)).toList(),errorCode: json['errorCode'],errorMsg: json['errorMsg'],);}Map<String, dynamic> toJson() => {'data': data.map((e) => e.toJson()).toList(),'errorCode': errorCode,'errorMsg': errorMsg,};
}class ProjectCategory {final List<dynamic> articleList; // 为空数组,暂时用 dynamic 占位final String author;final List<dynamic> children; // 同上final int courseId;final String cover;final String desc;final int id;final String lisense;final String lisenseLink;final String name;final int order;final int parentChapterId;final int type;final bool userControlSetTop;final int visible;ProjectCategory({required this.articleList,required this.author,required this.children,required this.courseId,required this.cover,required this.desc,required this.id,required this.lisense,required this.lisenseLink,required this.name,required this.order,required this.parentChapterId,required this.type,required this.userControlSetTop,required this.visible,});factory ProjectCategory.fromJson(Map<String, dynamic> json) {return ProjectCategory(articleList: json['articleList'] ?? [],author: json['author'] ?? '',children: json['children'] ?? [],courseId: json['courseId'],cover: json['cover'] ?? '',desc: json['desc'] ?? '',id: json['id'],lisense: json['lisense'] ?? '',lisenseLink: json['lisenseLink'] ?? '',name: json['name'] ?? '',order: json['order'],parentChapterId: json['parentChapterId'],type: json['type'],userControlSetTop: json['userControlSetTop'],visible: json['visible'],);}Map<String, dynamic> toJson() => {'articleList': articleList,'author': author,'children': children,'courseId': courseId,'cover': cover,'desc': desc,'id': id,'lisense': lisense,'lisenseLink': lisenseLink,'name': name,'order': order,'parentChapterId': parentChapterId,'type': type,'userControlSetTop': userControlSetTop,'visible': visible,};
}

项目文章列表widgets/project_article_item.dart

class ProjectArticleItemLayout extends StatefulWidget {final ProjectArticle projectArticle; // 文章数据模型final void Function() onCollectTap; // 收藏按钮点击回调const ProjectArticleItemLayout({Key? key,required this.projectArticle,required this.onCollectTap,}) : super(key: key);_ProjectArticleItemState createState() => _ProjectArticleItemState();
}class _ProjectArticleItemState extends State<ProjectArticleItemLayout> {Widget build(BuildContext context) {final article = widget.projectArticle;String publishTime = DateTime.fromMillisecondsSinceEpoch(article.publishTime).toString().substring(0, 16); // 保留 "yyyy-MM-dd HH:mm"return Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),child: GestureDetector(onTap: widget.onCollectTap,child: Card(surfaceTintColor: Colors.white,color: Colors.white,elevation: 4,shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),child: Padding(padding: const EdgeInsets.all(8),child: Row(children: [// 左侧封面图ClipRRect(borderRadius: BorderRadius.circular(8),child: Image.network(article.envelopePic,width: 96,height: 96,fit: BoxFit.cover,errorBuilder: (_, __, ___) => Container(width: 96,height: 96,color: Colors.grey[300],child: const Icon(Icons.broken_image, color: Colors.grey),),),),const SizedBox(width: 12),// 右侧内容区Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start,children: [// 标题Text(article.title,style: const TextStyle(fontSize: 16,fontWeight: FontWeight.bold,),maxLines: 1,overflow: TextOverflow.ellipsis,),const SizedBox(height: 4),// 简介内容Text(article.desc,style: const TextStyle(fontSize: 14, color: Colors.black87),maxLines: 3,overflow: TextOverflow.ellipsis,),const SizedBox(height: 8),// 作者和时间 + 收藏按钮Row(mainAxisAlignment: MainAxisAlignment.spaceBetween,children: [Text("${article.author} · $publishTime",style: TextStyle(fontSize: 12, color: Colors.grey[600]),),],)],),),],),),),),);}
}

项目界面的 ViewModel project/project_page_view_model.dart

class ProjectPageViewModel extends ChangeNotifier {List<ProjectCategory> _projectCategoryList = [];List<ProjectCategory> get projectCategoryList => _projectCategoryList;List<ProjectArticle> _projectArticleList = [];List<ProjectArticle> get projectArticleList => _projectArticleList;Future<void> loadProjectCategory() async {final response = await ApiService.loadProjectCategory();_projectCategoryList = ProjectCategoryResponse.fromJson(response).data;notifyListeners();}// 加载文章数据,page 为页码(0 表示首页刷新)Future<void> loadProjectArticles(int page, int cid) async {// 调用 API 获取文章列表数据final response = await ApiService.loadProjectList(page, cid);// 解析响应数据中的文章列表final newList = ProjectArticleResponse.fromJson(response).data.datas;if (page == 1) {// 如果是第一页,重置整个文章列表_projectArticleList = newList;} else {// 否则追加到原有列表后_projectArticleList.addAll(newList);}// 通知 UI 更新notifyListeners();}
}

project/project_page.dart

class ProjectPage extends StatelessWidget {const ProjectPage({super.key});Widget build(BuildContext context) {// 使用ChangeNotifierProvider提供ProjectPageViewModel状态管理// create参数创建ViewModel实例// child参数指定子组件_ProjectPageBodyreturn ChangeNotifierProvider<ProjectPageViewModel>(create: (_) => ProjectPageViewModel(), // 创建 ViewModelchild: _ProjectPageBody(), // 子组件主体);}
}// ProjectPage的页面主体,使用StatefulWidget以便进行生命周期控制
// 与TabController和ScrollController配合使用需要StatefulWidget
class _ProjectPageBody extends StatefulWidget {_ProjectPageState createState() => _ProjectPageState();
}class _ProjectPageState extends State<_ProjectPageBody>with BasePage<_ProjectPageBody>, SingleTickerProviderStateMixin {late TabController _tabController; // 控制Tab栏的控制器late final ScrollController _scrollController; // 控制列表滚动的控制器int selectedIndex = 0; // 当前选中的Tab索引int currentPage = 1;   // 当前加载的页码int currentId = 0;     // 当前分类IDvoid initState() {super.initState();// 初始化滚动控制器并添加滚动监听_scrollController = ScrollController();_scrollController.addListener(_onScroll); // 绑定滚动监听方法// 在Widget树构建完成后执行初始化操作WidgetsBinding.instance.addPostFrameCallback((_) {final vm = context.read<ProjectPageViewModel>();// 加载项目分类数据vm.loadProjectCategory().then((_) {// 检查组件是否仍然挂载if (mounted) {setState(() {// 初始化TabController,长度等于分类数量_tabController = TabController(length: vm.projectCategoryList.length,vsync: this, // 使用SingleTickerProviderStateMixin提供vsyncinitialIndex: selectedIndex, // 初始选中索引);});// 设置当前分类ID并加载第一页文章currentId = vm.projectCategoryList[selectedIndex].id;vm.loadProjectArticles(currentPage, currentId);}});});}void dispose() {_tabController.dispose(); // 释放TabController资源_scrollController.dispose(); // 释放ScrollController资源super.dispose();}Widget build(BuildContext context) {// 监听项目分类列表变化final projectCategoryList =context.watch<ProjectPageViewModel>().projectCategoryList;// 监听项目文章列表变化final projectArticleList =context.watch<ProjectPageViewModel>().projectArticleList;return Column(children: [// 顶部水平Tab栏Container(color: Colors.white,child: TabBar(controller: _tabController, // 绑定TabControllerisScrollable: true, // 允许横向滚动indicatorColor: Colors.blue, // 指示器颜色labelColor: Colors.blue, // 选中标签颜色unselectedLabelColor: Colors.black54, // 未选中标签颜色tabs: projectCategoryList.map((e) => Tab(text: e.name)).toList(), // 生成Tab列表tabAlignment: TabAlignment.start, // 标签对齐方式onTap: (index) {// Tab点击事件处理setState(() {selectedIndex = index; // 更新选中索引currentId = context.read<ProjectPageViewModel>().projectCategoryList[selectedIndex].id; // 更新当前分类IDcurrentPage = 1; // 重置页码});// 加载新分类的第一页数据context.read<ProjectPageViewModel>().loadProjectArticles(currentPage, currentId);},),),// 内容区域,使用Expanded填满剩余空间Expanded(child: Container(color: Colors.white,child: ListView.builder(controller: _scrollController, // 绑定滚动控制器itemCount: projectArticleList.length, // 列表项数量itemBuilder: (_, i) {// 构建每个文章项return ProjectArticleItemLayout(projectArticle: projectArticleList[i],onCollectTap: () {// 收藏点击事件ToastUtils.showToast(context, "你点击了 ${projectArticleList[i].title}");},);},),),)],);}// 滚动监听方法void _onScroll() {final maxScroll = _scrollController.position.maxScrollExtent; // 最大滚动范围final currentScroll = _scrollController.position.pixels; // 当前滚动位置// 当滚动到接近底部(距离底部50像素)时触发加载更多if (currentScroll >= maxScroll - 50) {_loadMore();}}/// 加载更多文章数据Future<void> _loadMore() async {currentPage++; // 页码增加// 加载下一页数据await context.read<ProjectPageViewModel>().loadProjectArticles(currentPage, currentId);}
}

至此,我们完成了首页、体系、导航、项目界面的设计,由于文章篇幅过长,剩余内容在下一章节完善。

http://www.dtcms.com/a/284720.html

相关文章:

  • 【WPF】WPF 自定义控件之依赖属性
  • Matlab2025a软件安装|详细安装步骤➕安装文件|附下载文件
  • Mask2Former,分割新范式
  • Kafka 控制器(Controller)详解:架构、原理与实战
  • Python23 —— 标准库(time库)
  • c++列表初始化
  • Dijkstra 算法求解多种操作
  • Stone3D教程:免编码制作在线家居生活用品展示应用
  • 【初始Java】
  • mysql中where字段的类型转换
  • (转)Kubernetes基础介绍
  • SQL增查
  • Windows下odbc配置连接SQL Server
  • .Net将控制台的输出信息存入到日志文件按分钟生成日志文件
  • 【JavaEE进阶】使用云服务器搭建Linux环境
  • Java网络通信:UDP和TCP
  • 关于CDH以及HUE的介绍
  • vue-seo优化
  • Android构建流程与Transform任务
  • 题解:P13311 [GCJ 2012 Qualification] Speaking in Tongues
  • java面向对象-多态
  • 【前端】Power BI自动化指南:从API接入到Web嵌入
  • 旅游管理实训基地建设:筑牢文旅人才培养的实践基石
  • LeetCode热题100—— 238. 除自身以外数组的乘积
  • Pygame创建窗口教程 - 从入门到实践 | Python游戏开发指南
  • 小白学Python,网络爬虫篇(1)——requests库
  • java Integer怎么获取长度
  • 【Jmeter】报错:An error occured:Unknown arg
  • 3.PCL点云合并
  • 为什么选择Selenium自动化测试?