1.旅拍界面展示
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 数据展示部分布局:
- 最外层是一个
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 提交记录为:旅拍界面布局实现。