很多時候,我們需要一些特效功能,比如給圖片做個濾鏡什么的,如果是h5頁面,那么我們可以很容易的通過css濾鏡來實(shí)現(xiàn)這個功能。
(資料圖)
那么如果在flutter中,如果要實(shí)現(xiàn)這樣的濾鏡功能應(yīng)該怎么處理呢?一起來看看吧。
我們的目標(biāo)在繼續(xù)進(jìn)行之前,我們先來討論下本章到底要做什么。最終的目標(biāo)是希望能夠?qū)崿F(xiàn)一個圖片的濾鏡功能。
那么我們的app界面實(shí)際上可以分為兩個部分。第一個部分就是帶濾鏡效果的圖片,第二個部分就是可以切換的濾鏡按鈕。
接下來我們一步步來看如何實(shí)現(xiàn)這些功能。
帶濾鏡的圖片要實(shí)現(xiàn)這個功能其實(shí)比較簡單,我們構(gòu)建一個widget,因?yàn)檫@個widget中的圖片需要根據(jù)自身選擇的濾鏡顏色來改變圖片的狀態(tài),所以這里我們需要的是一個StatefulWidget,在state里面,存儲的就是當(dāng)前的_filterColor。
構(gòu)建一個圖片的widget的代碼可以如下所示:
class ImageFilterApp extends StatefulWidget { const ImageFilterApp({super.key}); @override State createState() => _ImageFilterAppState();}class _ImageFilterAppState extends State { final _filters = [ Colors.white, ...Colors.primaries ]; final _filterColor = ValueNotifier(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可以把a(bǔ)pp的視圖填滿。
在_buildPhotoWithFilter方法中,我們返回了Image.asset,里面可以設(shè)置image的color和colorBlendMode。這兩個值就是圖片濾鏡的關(guān)鍵。
就這么簡單?一個圖片濾鏡就完成了?對的就是這么簡單。圖片濾鏡就是Image.asset中自帶的功能。
但是在實(shí)際的應(yīng)用中,這個color不會是固定的,是需要根據(jù)我們的不同選擇而進(jìn)行變化的。為了能夠接受到這個變化的值,我們使用了ValueListenableBuilder,通過傳入一個可變的ValueNotifier,來實(shí)現(xiàn)監(jiān)聽color變化的結(jié)果。
final _filterColor = ValueNotifier(Colors.white); void _onFilterChanged(Color value) { _filterColor.value = value; }
另外,我們提供了一個觸發(fā)_filterColor的值進(jìn)行變化的方法_onFilterChanged。
上面的代碼運(yùn)行的結(jié)果如下:
很好,現(xiàn)在我們已經(jīng)有了一個帶有顏色filter功能的界面了。 接下來我們還需要一個filter的按鈕,來觸發(fā)filter顏色的變化。
打造filter按鈕這里我們的filter包含了Colors.primaries中所有的顏色再加上一個自定義的白色。
每一個filter按鈕其實(shí)都可以用一個widget來表示。我們希望是一個圓形的filter按鈕,里面有一個圖片的小的縮略圖來展示filter的效果。
另外通過tap對應(yīng)的filter按鈕,還可以實(shí)現(xiàn)color切換的功能。
所以對于Filter按鈕widget來說,可以接收兩個參數(shù),一個是當(dāng)前的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, ), ), ), ), ); }
打造可滑動按鈕上一節(jié)我們創(chuàng)建好了filter按鈕,接下來就是把filter按鈕組裝起來,形成一個可滑動的filter按鈕組件。
要想滑動widget,我們可以使用Scrollable組件,通過傳入一個PageController來控制PageView的展示。
Scrollable出了controller之外,還有一個非常重要的屬性就是viewportBuilder。在viewportBuilder中可以傳入viewportOffset。
當(dāng)Scrollable滑動的時候,viewportOffset中的pixels是會動態(tài)變化的。我們可以根據(jù)viewportOffset中的pixels的變化來重繪filter按鈕。
如果要根據(jù)viewportOffset的變化來重新定位child組件的位置的話,最好的方式就是將其包裹在Flow組件中。
因?yàn)镕low提供了一個FlowDelegate,我們可以在FlowDelegate中根據(jù)viewportOffset的不同來重繪filter widget。這個FlowDelegate的實(shí)現(xiàn)如下:
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; // 一個單獨(dú)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被替換的情況下觸發(fā) return oldDelegate.viewportOffset != viewportOffset; }}
在paintChildren的最后,我們通過調(diào)用context.paintChild來重繪child。
可以看到這里傳入了三個參數(shù),第一個參數(shù)是child的index,這個index指的是創(chuàng)建Flow時候傳入的children數(shù)組中的index:
Flow( delegate: CarouselFlowDelegate( viewportOffset: viewportOffset, filtersPerScreen: _filtersPerScreen, ), children: [ for (int i = 0; i < filterCount; i++) FilterItem( onFilterSelected: () => _onFilterTapped(i), color: itemColor(i), ), ], )
最后,我們把創(chuàng)建Flow的方法_buildCarousel放到Scrollable中去,并將viewportOffset作為Flow的構(gòu)造函數(shù)參數(shù)傳入,從而實(shí)現(xiàn)Flow根據(jù)Scrollable的滑動而發(fā)送相應(yīng)的變化:
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, ), ], ); }, ); }, );
最后要解決的問題到目前為止,一切看起來都很好。但是如果你仔細(xì)研究的話可能會產(chǎn)生一個疑問。那就是Scrollable的controller是PageController,我們是通過PageController中的page來切換對應(yīng)的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的構(gòu)造函數(shù):
_controller = PageController( initialPage: _page, viewportFraction: _viewportFractionPerItem, );
除了初始化的initialPage之外,還有一個viewportFraction。這個值就是指一個view可以被分成多少個page。
以我的iphone14為例,它的constraints.maxWidth=390.0, 如果被分成5份的話,一份的值是78.0。 也就是說當(dāng)Scrollable滑動78,的時候,page就從0變成1了。這和我們在Flow中重繪child時候,取的index是一致的。
最后,效果圖如下:
本文的例子:https://github.com/ddean2009/learn-flutter.git