很多時候,我們需要一些特效功能,比如給圖片做個濾鏡什麼的,如果是h5頁面,那麼我們可以很容易的通過css濾鏡來實現這個功能。
那麼如果在flutter中,如果要實現這樣的濾鏡功能應該怎麼處理呢?一起來看看吧。
在繼續進行之前,我們先來討論下本章到底要做什麼。最終的目標是希望能夠實現一個圖片的濾鏡功能。
那麼我們的app介面實際上可以分為兩個部分。第一個部分就是帶濾鏡效果的圖片,第二個部分就是可以切換的濾鏡按鈕。
接下來我們一步步來看如何實現這些功能。
要實現這個功能其實比較簡單,我們構建一個widget,因為這個widget中的圖片需要根據自身選擇的濾鏡顏色來改變圖片的狀態,所以這裡我們需要的是一個StatefulWidget,在state裡面,儲存的就是當前的_filterColor。
構建一個圖片的widget的程式碼可以如下所示:
class ImageFilterApp extends StatefulWidget {
const ImageFilterApp({super.key});
@override
State<ImageFilterApp> createState() =>
_ImageFilterAppState();
}
class _ImageFilterAppState
extends State<ImageFilterApp> {
final _filters = [
Colors.white,
...Colors.primaries
];
final _filterColor = ValueNotifier<Color>(Colors.white);
void _onFilterChanged(Color value) {
_filterColor.value = value;
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black,
child: Stack(
children: [
Positioned.fill(
child: _buildPhotoWithFilter(),
),
],
),
);
}
Widget _buildPhotoWithFilter() {
return ValueListenableBuilder(
valueListenable: _filterColor,
builder: (context, value, child) {
final color = value;
return Image.asset(
'images/head.jpg',
color: color.withOpacity(0.5),
colorBlendMode: BlendMode.color,
fit: BoxFit.cover,
);
},
);
}
}
在build方法中,我們返回了一個Positioned.fill填充的widget,這個widget可以把app的檢視填滿。
在_buildPhotoWithFilter方法中,我們返回了Image.asset,裡面可以設定image的color和colorBlendMode。這兩個值就是圖片濾鏡的關鍵。
就這麼簡單?一個圖片濾鏡就完成了?對的就是這麼簡單。圖片濾鏡就是Image.asset中自帶的功能。
但是在實際的應用中,這個color不會是固定的,是需要根據我們的不同選擇而進行變化的。為了能夠接受到這個變化的值,我們使用了ValueListenableBuilder,通過傳入一個可變的ValueNotifier,來實現監聽color變化的結果。
final _filterColor = ValueNotifier<Color>(Colors.white);
void _onFilterChanged(Color value) {
_filterColor.value = value;
}
另外,我們提供了一個觸發_filterColor的值進行變化的方法_onFilterChanged。
上面的程式碼執行的結果如下:
很好,現在我們已經有了一個帶有顏色filter功能的介面了。 接下來我們還需要一個filter的按鈕,來觸發filter顏色的變化。
這裡我們的filter包含了Colors.primaries中所有的顏色再加上一個自定義的白色。
每一個filter按鈕其實都可以用一個widget來表示。我們希望是一個圓形的filter按鈕,裡面有一個圖片的小的縮圖來展示filter的效果。
另外通過tap對應的filter按鈕,還可以實現color切換的功能。
所以對於Filter按鈕widget來說,可以接收兩個引數,一個是當前的color,另外一個是tap之後的VoidCallback onFilterSelected, 所以最終我們的FilterItem是下面的樣子的:
class FilterItem extends StatelessWidget {
const FilterItem({
super.key,
required this.color,
this.onFilterSelected,
});
final Color color;
final VoidCallback? onFilterSelected;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onFilterSelected,
child: AspectRatio(
aspectRatio: 1.0,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ClipOval(
child: Image.asset(
'images/head.jpg',
color: color.withOpacity(0.5),
colorBlendMode: BlendMode.hardLight,
),
),
),
),
);
}
上一節我們建立好了filter按鈕,接下來就是把filter按鈕組裝起來,形成一個可滑動的filter按鈕元件。
要想滑動widget,我們可以使用Scrollable元件,通過傳入一個PageController來控制PageView的展示。
Scrollable出了controller之外,還有一個非常重要的屬性就是viewportBuilder。在viewportBuilder中可以傳入viewportOffset。
當Scrollable滑動的時候,viewportOffset中的pixels是會動態變化的。我們可以根據viewportOffset中的pixels的變化來重繪filter按鈕。
如果要根據viewportOffset的變化來重新定位child元件的位置的話,最好的方式就是將其包裹在Flow元件中。
因為Flow提供了一個FlowDelegate,我們可以在FlowDelegate中根據viewportOffset的不同來重繪filter widget。這個FlowDelegate的實現如下:
class CarouselFlowDelegate extends FlowDelegate {
CarouselFlowDelegate({
required this.viewportOffset,
required this.filtersPerScreen,
}) : super(repaint: viewportOffset);
final ViewportOffset viewportOffset;
final int filtersPerScreen;
@override
void paintChildren(FlowPaintingContext context) {
print(viewportOffset.pixels);
final count = context.childCount;
//繪製寬度
final size = context.size.width;
// 一個單獨item的寬度
final itemExtent = size / filtersPerScreen;
// active item的index
final active = viewportOffset.pixels / itemExtent;
print('active$active');
// 要繪製的最小的index,在active item的左邊最多繪製3個items
final min = math.max(0, active.floor() - 3).toInt();
//要繪製的最大index,在active item的右邊最多繪製3個items
final max = math.min(count - 1, active.ceil() + 3).toInt();
// 重新繪製要展示的item
for (var index = min; index <= max; index++) {
final itemXFromCenter = itemExtent * index - viewportOffset.pixels;
final percentFromCenter = 1.0 - (itemXFromCenter / (size / 2)).abs();
final itemScale = 0.5 + (percentFromCenter * 0.5);
final opacity = 0.25 + (percentFromCenter * 0.75);
final itemTransform = Matrix4.identity()
..translate((size - itemExtent) / 2)
..translate(itemXFromCenter)
..translate(itemExtent / 2, itemExtent / 2)
..multiply(Matrix4.diagonal3Values(itemScale, itemScale, 1.0))
..translate(-itemExtent / 2, -itemExtent / 2);
context.paintChild(
index,
transform: itemTransform,
opacity: opacity,
);
}
}
@override
bool shouldRepaint(covariant CarouselFlowDelegate oldDelegate) {
//viewportOffset被替換的情況下觸發
return oldDelegate.viewportOffset != viewportOffset;
}
}
在paintChildren的最後,我們通過呼叫context.paintChild來重繪child。
可以看到這裡傳入了三個引數,第一個引數是child的index,這個index指的是建立Flow時候傳入的children陣列中的index:
Flow(
delegate: CarouselFlowDelegate(
viewportOffset: viewportOffset,
filtersPerScreen: _filtersPerScreen,
),
children: [
for (int i = 0; i < filterCount; i++)
FilterItem(
onFilterSelected: () => _onFilterTapped(i),
color: itemColor(i),
),
],
)
最後,我們把建立Flow的方法_buildCarousel放到Scrollable中去,並將viewportOffset作為Flow的建構函式引數傳入,從而實現Flow根據Scrollable的滑動而傳送相應的變化:
Widget build(BuildContext context) {
return Scrollable(
controller: _controller,
axisDirection: AxisDirection.right,
physics: const PageScrollPhysics(),
viewportBuilder: (context, viewportOffset) {
return LayoutBuilder(
builder: (context, constraints) {
final itemSize = constraints.maxWidth * _viewportFractionPerItem;
viewportOffset
..applyViewportDimension(constraints.maxWidth)
..applyContentDimensions(0.0, itemSize * (filterCount - 1));
return Stack(
alignment: Alignment.bottomCenter,
children: [
_buildCarousel(
viewportOffset: viewportOffset,
itemSize: itemSize,
),
],
);
},
);
},
);
到目前為止,一切看起來都很好。但是如果你仔細研究的話可能會產生一個疑問。那就是Scrollable的controller是PageController,我們是通過PageController中的page來切換對應的filter顏色的:
void _onPageChanged() {
print('page${_controller.page}');
final page = (_controller.page ?? 0).round();
if (page != _page) {
_page = page;
widget.onFilterChanged(widget.filters[page]);
}
}
那麼這個page是如何變化的呢?什麼時候從0變成1呢?
我們先來看下PageController的建構函式:
_controller = PageController(
initialPage: _page,
viewportFraction: _viewportFractionPerItem,
);
除了初始化的initialPage之外,還有一個viewportFraction。這個值就是指一個view可以被分成多少個page。
以我的iphone14為例,它的constraints.maxWidth=390.0, 如果被分成5份的話,一份的值是78.0。 也就是說當Scrollable滑動78,的時候,page就從0變成1了。這和我們在Flow中重繪child時候,取的index是一致的。
最後,效果圖如下:
本文的例子:https://github.com/ddean2009/learn-flutter.git