Skip to content

Instantly share code, notes, and snippets.

@AlexV525
Last active April 2, 2021 07:58
Show Gist options
  • Select an option

  • Save AlexV525/e5ec9f789b1d8458ef592c5cbd09dd4e to your computer and use it in GitHub Desktop.

Select an option

Save AlexV525/e5ec9f789b1d8458ef592c5cbd09dd4e to your computer and use it in GitHub Desktop.
Customized icon grid view
///
/// [Author] Alex (https://github.com/AlexV525)
/// [Date] 4/2/21 12:48 PM
///
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Test Icon Grid Demo',
theme: ThemeData(primarySwatch: Colors.blue),
home: const TestIconGridPage(),
);
}
}
class TestIconGridPage extends StatefulWidget {
const TestIconGridPage({Key? key}) : super(key: key);
@override
_TestIconGridPageState createState() => _TestIconGridPageState();
}
class _TestIconGridPageState extends State<TestIconGridPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Column(
children: <Widget>[
const _ItemGridView(
items: <_ItemData>[
_ItemData(icon: Icons.android, label: '测试1'),
_ItemData(icon: Icons.domain, label: '测试2'),
_ItemData(icon: Icons.portrait, label: '测试3'),
_ItemData(icon: Icons.landscape, label: '测试4'),
_ItemData(icon: Icons.android, label: '测试1'),
_ItemData(icon: Icons.domain, label: '测试2'),
_ItemData(icon: Icons.portrait, label: '测试3'),
_ItemData(icon: Icons.landscape, label: '测试4'),
_ItemData(icon: Icons.android, label: '测试1'),
_ItemData(icon: Icons.domain, label: '测试2'),
_ItemData(icon: Icons.portrait, label: '测试3'),
_ItemData(icon: Icons.landscape, label: '测试4'),
],
shouldAdaptLastPageHeight: true,
),
Expanded(child: Container(color: Colors.green)),
],
),
);
}
}
class _ItemData {
const _ItemData({
required this.icon,
required this.label,
});
final IconData icon;
final String label;
}
class _ItemBuilder extends StatelessWidget {
const _ItemBuilder({
Key? key,
required this.item,
required this.height,
}) : super(key: key);
final _ItemData item;
final double height;
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints.tightFor(height: height),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
Icon(item.icon),
Text(item.label),
],
),
);
}
}
class _ItemGridView extends StatefulWidget {
const _ItemGridView({
Key? key,
required this.items,
this.mainAxisCount = 2,
this.crossAxisCount = 5,
this.itemHeight = 60,
this.shouldAdaptLastPageHeight = false,
this.withIndicator = true,
this.indicatorBuilder,
}) : assert(crossAxisCount > 0),
super(key: key);
final List<_ItemData> items;
/// 行数
final int mainAxisCount;
/// 列数
final int crossAxisCount;
/// 单个 item 的高度
final double itemHeight;
/// 是否需要根据最后一页的数量,自适应高度
final bool shouldAdaptLastPageHeight;
/// 是否需要指示器
final bool withIndicator;
/// 自定义指示器构建
final PreferredSizeWidget Function(PageController controller, int pageCount)?
indicatorBuilder;
@override
_ItemGridViewState createState() => _ItemGridViewState();
}
class _ItemGridViewState extends State<_ItemGridView> {
final ValueNotifier<double> _page = ValueNotifier<double>(0);
late final PageController _pgc = PageController()
..addListener(() {
if (_pgc.hasClients) {
_page.value = _pgc.page!;
}
});
/// 共计高度
double get _height => widget.mainAxisCount * widget.itemHeight;
/// 每页的 item 数量
int get _countPerPage => widget.mainAxisCount * widget.crossAxisCount;
/// 共计页数
int get _pageCount => (widget.items.length / _countPerPage).ceil();
/// 最后一页需要放置的 item 数量
int get _lastPageItemsCount => widget.items.length % _countPerPage;
/// 最后一页的实际占位高度
double get _lastPageHeight =>
(_lastPageItemsCount / widget.crossAxisCount).ceil() * widget.itemHeight;
/// 是否存在不铺满一页的最后一页
bool get hasLastPage => _lastPageItemsCount > 0;
/// 指示器
PreferredSizeWidget get _effectiveIndicator =>
widget.indicatorBuilder?.call(_pgc, _pageCount) ??
_DotsIndicator(controller: _pgc, itemCount: _pageCount);
/// 计算当前页实际需要的高度
///
/// 如果是最后一页,则实时计算滚动的过程,来合成高度。
double _currentPageHeight(double page, bool isLastPage) {
if (hasLastPage &&
page > _pageCount - 2 &&
widget.shouldAdaptLastPageHeight) {
return lerpDouble(
_height,
_lastPageHeight,
isLastPage ? 1 : page - page.floor(),
)!;
}
return _height;
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<double>(
valueListenable: _page,
builder: (_, double page, Widget? child) => ConstrainedBox(
constraints: BoxConstraints.tightFor(
height: _currentPageHeight(page, page == _pageCount - 1) +
(widget.withIndicator
? _effectiveIndicator.preferredSize.height
: 0),
),
child: child!,
),
child: Column(
children: <Widget>[
Expanded(
child: LayoutBuilder(
builder: (_, BoxConstraints constraints) => PageView.builder(
controller: _pgc,
itemCount: _pageCount,
itemBuilder: (_, int i) => Wrap(
children: List<Widget>.generate(
// 当为最后一页且最后一页不铺满时,使用最后一页的计数。
i == _pageCount - 1 && hasLastPage
? _lastPageItemsCount
: _countPerPage,
(int index) => ConstrainedBox(
constraints: BoxConstraints.tightFor(
width: constraints.maxWidth / widget.crossAxisCount,
),
child: _ItemBuilder(
item: widget.items[i * _countPerPage + index],
height: widget.itemHeight,
),
),
),
),
),
),
),
if (widget.withIndicator)
ConstrainedBox(
constraints: BoxConstraints.tight(
_effectiveIndicator.preferredSize,
),
child: _effectiveIndicator,
),
],
),
);
}
}
class _DotsIndicator extends AnimatedWidget implements PreferredSizeWidget {
const _DotsIndicator({
required this.controller,
required this.itemCount,
this.itemWidth = 30.0,
this.onPageSelected,
this.maxWidth = 24.0,
this.height = 20.0,
this.size = 8.0,
this.color = Colors.black,
this.colors,
this.unselectedColor = Colors.grey,
}) : assert(
color == null || colors == null,
'Cannot provide color and colors at the same time.',
),
super(listenable: controller);
/// The [PageController] that this [DotsIndicator] is representing.
final PageController controller;
/// The number of items managed by the [controller].
final int itemCount;
/// The width for each dot.
final double itemWidth;
/// Called when a dot is tapped.
final ValueChanged<int>? onPageSelected;
/// The maximum width of the indicator.
final double maxWidth;
/// The height of the indicator.
final double height;
/// The color of the dots.
/// Defaults to [Colors.black].
final Color? color;
final List<Color>? colors;
/// The color of the dots which is unselected.
/// Defaults to [Colors.grey.
final Color unselectedColor;
/// The size of the dots.
final double size;
void _onPageSelected(int index) {
controller.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.fastOutSlowIn,
);
}
Widget _buildDot(int index) {
final num page =
(controller.hasClients ? controller.page : 0) ?? controller.initialPage;
final double unselected = Curves.easeOut.transform(
math.max(0.0, 1.0 - (page - index).abs()),
);
final double zoom = 1.0 + (maxWidth / size - 1.0) * unselected;
List<Color>? _colors;
if (colors != null) {
_colors = <Color>[
Color.lerp(unselectedColor, colors![0], unselected)!,
Color.lerp(unselectedColor, colors![1], unselected)!,
];
}
return SizedBox(
width: itemWidth,
child: Center(
child: Container(
width: size * zoom,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(size),
color: _colors == null
? Color.lerp(unselectedColor, color, unselected)
: (color ?? unselectedColor),
gradient: (_colors != null)
? LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: _colors,
)
: null,
),
child: GestureDetector(onTap: () => _onPageSelected(index)),
),
),
);
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: height,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List<Widget>.generate(itemCount, _buildDot),
),
);
}
@override
Size get preferredSize => Size(itemWidth * itemCount, height);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment