Flutter开发学习课程携程app开发(二)

1.旅拍界面展示

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Kve6nUfk-1636438154905)(../pic/1636375375031.gif)]

2.界面布局

  • 顶部是 TabBar 配合 TabBarView 实现页面滑动翻页
  • TabBarView 用 Flexible 包裹 Flexible 包裹充满整个页面
  • 内容区部分采用 StaggeredGridView 构建瀑布流式布局,引入插件 flutter_staggered_grid_view
  • 自定义 LoadingContainer 在进入界面的时候弹出一个加载菊花。
  • 通过 RefreshIndicator 控件实现下拉刷新。
  • 通过 MediaQuery.removePadding 移除导航栏间距。

TravelPage 页面布局如下,顶部的 TabBar 切换

///旅拍主界面
class TravelPage extends StatefulWidget {
  TravelPage({Key? key}) : super(key: key);

  @override
  _TravelPageState createState() => _TravelPageState();
}

class _TravelPageState extends State<TravelPage>
    with TickerProviderStateMixin {
  // List<String> tabs = ["推荐", "附近", "热门", "旅行热点", "露营初体验", "酒店民宿",
  //   "美食探店", "亲子", "小众", "自驾", "网红", "逛展"];

  List<TravelTab> tabs = [];
  TravelTabModel? travelTabModel;

  late TabController _controller;

  @override
  void initState() {
    print("TravelPage initState...");
    super.initState();
    _controller = TabController(length: tabs.length, vsync: this);
    _loadData();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          Container(
            color: Colors.white,
            padding: const EdgeInsets.only(top: 30),
            child: TabBar(
                controller: _controller,
                isScrollable: true,
                labelColor: Colors.black,
                labelPadding: const EdgeInsets.fromLTRB(20, 0, 20, 5),
                indicator: const UnderlineTabIndicator(
                  borderSide: BorderSide(color: Color(0xff1fcfbb), width: 3),
                  insets: EdgeInsets.only(bottom: 10),
                ),
                tabs: tabs.map<Tab>((TravelTab tab) {
                  return Tab(text: tab.labelName);
                }).toList()),
          ),
          Flexible(
              child: TabBarView(
            controller: _controller,
            children: tabs.map<TravelItemPage>((TravelTab tab) {
              return TravelItemPage(
                travelUrl: travelTabModel!.url,
                params: travelTabModel!.params,
                groupChannelCode: tab.groupChannelCode,
                type: tab.type,
              );
            }).toList(),
          ))
        ],
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  //初始化tab数据
  void _loadData() async {
    try {
      TravelTabModel model = await TravelTabDao.fetch();
      _controller = TabController(
          length: model.tabs.length, vsync: this); // fix tab label 空白问题
      setState(() {
        tabs = model.tabs;
        travelTabModel = model;
      });
    } catch (e) {
      print(e);
    }
  }
}

Body 数据展示部分布局:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qSCMDXwM-1636438154907)(../pic/image-20211109111346230.png)]

  • 最外层是一个 Card 布局,支持设置阴影,形状等,如图中的 5 所示。内部用一个PhysicalModel用于裁剪圆角等,设置裁剪透明。
  • 内部放置一个 Column 布局,上面显示图片等,下面显示描述信息和用户信心。
  • 如图标注 1 应该是一个 Stack 布局,放置一个 Image 和 一个绝对位置的 Positioned =》放置一个 Container =》 Row =》Padding +LimitedBox
  • 如图标注 3 是一个 Container =》Text
  • 如图标注 4 是 Container =》 Row =》PhysicalModel + Container +Row

完整代码如下:

///构建每个小卡片的样式
class _TravelItem extends StatelessWidget {
  final TravelItem item;
  final int index;

  const _TravelItem({Key? key, required this.item, required this.index})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {},
      child: Card(
        child: PhysicalModel(
          color: Colors.transparent,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start, //左对齐
            children: [
              _itemImage,
              Container(
                padding: const EdgeInsets.all(4),
                child: Text(
                  item.article.articleTitle,
                  maxLines: 2,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(fontSize: 14, color: Colors.black87),
                ),
              ),
              _infoText
            ],
          ),
        ),
      ),
    );
  }

  String _poiName() {
    return item.article.pois.isEmpty ? '未知' : item.article.pois[0].poiName;
  }

  ///卡片布局中的图片样式,采用 Stack 控件,图片加文字等
  Widget get _itemImage {
    return Stack(children: [
      CachedImage(imageUrl: item.article.images[0].dynamicUrl),
      //在图片上方放置一个绝对位置的布局
      Positioned(
          bottom: 8,
          left: 8,
          child: Container(
            padding: const EdgeInsets.fromLTRB(5, 1, 5, 1),
            decoration: BoxDecoration(
                color: Colors.black54, borderRadius: BorderRadius.circular(10)),
            child: Row(
              children: [
                const Padding(
                    padding: EdgeInsets.only(right: 3),
                    child: Icon(
                      Icons.location_on,
                      color: Colors.white,
                      size: 12,
                    )),
                //限制子控件大小的 Widget
                LimitedBox(
                  maxWidth: 130,
                  child: Text(
                    _poiName(),
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis, //尾部截断
                    style: const TextStyle(fontSize: 12, color: Colors.white),
                  ),
                ),
              ],
            ),
          )),
    ]);
  }

  //图片下面的用户信息展示
  Widget get _infoText {
    return Container(
      padding: const EdgeInsets.fromLTRB(6, 0, 6, 8),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          PhysicalModel(
            color: Colors.transparent,
            clipBehavior: Clip.antiAlias,
            borderRadius: BorderRadius.circular(12),
            child: CachedImage(
              imageUrl: item.article.author.coverImage.dynamicUrl,
              width: 24,
              height: 24,
            ),
          ),
          Container(
            padding: const EdgeInsets.all(5),
            width: 90,
            child: Text(
              item.article.author.nickName,
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: const TextStyle(fontSize: 12),
            ),
          ),
          Row(
            children: [
              const Icon(
                Icons.thumb_up,
                size: 14,
                color: Colors.grey,
              ),
              Padding(
                padding: const EdgeInsets.only(left: 3),
                child: Text(
                  item.article.likeCount.toString(),
                  style: const TextStyle(fontSize: 10),
                ),
              )
            ],
          )
        ],
      ),
    );
  }
}

整个页面布局如下:

const TRAVEL_URL =
    'https://m.ctrip.com/restapi/soa2/16189/json/searchTripShootListForHomePageV2?_fxpcqlniredt=09031014111431397988&__gw_appid=99999999&__gw_ver=1.0&__gw_from=10650013707&__gw_platform=H5';
const PAGE_SIZE = 10;

/// 旅拍视图展示界面 body 部分,瀑布流式布局
class TravelItemPage extends StatefulWidget {
  final String travelUrl;
  final Map params;
  final String groupChannelCode;
  final int type;

  const TravelItemPage(
      {Key? key,
      required this.travelUrl,
      required this.params,
      required this.groupChannelCode,
      required this.type})
      : super(key: key);

  @override
  _TravelItemPageState createState() => _TravelItemPageState();
}

class _TravelItemPageState extends State<TravelItemPage>
    with AutomaticKeepAliveClientMixin {
  //数据列表
  List<TravelItem> travelItems = [];

  //分页索引
  int pageIndex = 1;

  //是否正在加载
  bool _loading = true;

  @override
  bool get wantKeepAlive => true;

  final ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _loadData();
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: LoadingContainer(
        isLoading: _loading,
        child: RefreshIndicator(
          onRefresh: _handleRefresh,
          child: MediaQuery.removePadding(
            context: context,
            child: StaggeredGridView.countBuilder(
                controller: _scrollController,
                itemCount: travelItems.length,
                crossAxisCount: 2,
                itemBuilder: (BuildContext context, int index) =>
                    _TravelItem(index: index, item: travelItems[index]),
                staggeredTileBuilder: (int index) =>
                    const StaggeredTile.fit(1)),
            removeTop: true,
          ),
        ),
      ),
    );
  }

  /// 下拉刷新
  Future _handleRefresh() async {
    _loadData();
  }

  /// 加载数据,是下拉刷新还是加载更多
  void _loadData({loadMore = false}) async {
    if (loadMore) {
      pageIndex++;
    } else {
      pageIndex = 1;
    }
    try {
      TravelModel model = await TravelDao.fetch(widget.travelUrl, widget.params,
          widget.groupChannelCode, widget.type, pageIndex, PAGE_SIZE);
      setState(() {
        print(model.totalCount);
        List<TravelItem> items = model.resultList;
        if (travelItems.isNotEmpty) {
          travelItems.addAll(items);
        } else {
          travelItems = items;
        }
        _loading = false;
      });
    } catch (e) {
      print(e);
      setState(() {
        _loading = false;
      });
    }
  }
}

项目源码

  • 源码地址:Gihut
  • 该篇 git 提交记录为:旅拍界面布局实现。

在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值