Flutter - 手写体widgets之wired_elements

介绍

今天带大家一起看看wired_elements,Wired Elements 是一系列具有手绘外观的基本 UI 元素。

其实这种外观的UI元素在web端已经有非常成熟的组件库,请看这里。他是基于rough.js实现的一系列组件,可用于快速建立交互型产品设计稿,已经有基于此设计的可拖拽的网页端项目软件,大家可以搜一搜看看,我之前搜到过,不过当时没有收藏。。。也可用于自己blog的UI,也可以just for fun。总之web端是有了,但是Flutter我是没有看到,只有一个flutter_rough项目,他是rough.js的一个Flutter实现。

所以今天我就站在居然的肩膀上,写了一个Wired Elements的Flutter实现,先上图,后面会挑选1,2个widget说一下是如何实现的。

Flutter - 手写体widgets之wired_elements Flutter - 手写体widgets之wired_elements

Flutter - 手写体widgets之wired_elements Flutter - 手写体widgets之wired_elements

Flutter - 手写体widgets之wired_elements Flutter - 手写体widgets之wired_elements

Flutter - 手写体widgets之wired_elements Flutter - 手写体widgets之wired_elements

Flutter - 手写体widgets之wired_elements Flutter - 手写体widgets之wired_elements

巨人flutter_rough

上面说到了是站在巨人的肩膀上,就是说的flutter_rough,但是此组件库的作者没看到在维护了,似乎对这种样式的需求不太高?但是我们不管,just for fun!因为最新的flutter插件都需要null safety,但是flutter_rough并不是null safety的,已经有人建了一个issue提了这个问题,作者迟迟没有回复,所以这里我们没有办法,不能通过dependency的方式引入,只得把代码拷贝到自己的项目中自己做了null safety(这里已经在wired_elements的readme中说明标注了引入flutter_rough)。好了,基本工作已经完成,下面我们就可以开始切入我们wired_elements的组件库了。

初始化wired_elements组件库

首先,我们需要开发的是flutter&dart的package,所以我们按照官方的步骤一步步创建项目,做好初始化工作。

1. 创建项目:

flutter create --template=package wired_elements

2. 实现package wired_elements:如下图,lib文件夹下面有一个导出文件wired_elements.dart,2个文件夹roughsrc,其中rough是拷贝过来并做了null safety的flutter_rough组件库,src文件夹下面是我们需要实现的手写体widgets,可以看到目前为止一些基本的widgets都有了,但是还有很多没有比如日历、进度条等等,后面会继续迭代。src文件夹下面还有个canvas文件夹,里面包含的主要是一些通用canvas操作,方便开发我们的widgets,具体就不细说了,大家可以看源码,我们的主要任务是介绍一下具体的手写体widgets。

Flutter - 手写体widgets之wired_elements

手写体按钮 - wired_button

大家可以参考web端的按钮,主要是边框和文字,文字手写体我们直接引入google的hand writing字体即可,比较简单,但是这种手写体的边框如何实现?

其实很简单,我们隐藏Flutter按钮的边框,然后外部包一层Container,实现自定义的decoration即可,这里的自定义decoration已经被flutter_rough实现好了,所以我们拿来主义即可。

@override
Widget buildWiredElement() {
return Container(
  padding: EdgeInsets.zero,
  height: 42.0,
  decoration: RoughBoxDecoration(
	shape: RoughBoxShape.rectangle,
	borderStyle: RoughDrawingStyle(
	  width: 1,
	  color: borderColor,
	),
  ),
  child: SizedBox(
	height: double.infinity,
	child: TextButton(
	  style: TextButton.styleFrom(
		primary: textColor,
	  ),
	  child: child,
	  onPressed: onPressed,
	),
  ),
);
}

上面的代码片段我们使用了RoughBoxDecotation,它提供了具体的边框形状和样式供我们选择,我们这里使用了长方形并且指定了粗细和颜色,这样一个简单的wired_button就实现了,剩下的就是添加一些Flutter button本身的参数暴露出来即可。

如果大家看了wired_button的源码就知道,我们没有直接继承StatefulWidget或者StatelessWidget,我们自己写了WiredBaseWidget继承了StatelessWidget,然后wired_button继承WiredBaseWidget,并实现WiredBaseWidget的方法buildWiredElement()。

为什么要这么做呢?可以看到在WiredBaseWidget类中,我们包裹了一层RepaintBoundary,它是用来隔离屏幕canvas的重绘,因为我们使用了自定义的decoration,继承了BoxPainter,使用的是同一个canvas,这样屏幕上只要是用了这个自定义dcoration的就是使用了同一个canvas实例来绘制屏幕,如果屏幕上面有多个wired_button,那么当我们点击某一个按钮,他会触发重绘,如果我们不隔离重绘,其它的按钮也会跟着重绘,这并不是我们期望的,所以我们使用了RepaintBoundary来避免这个问题。

手写体滑件 - wired_slider

滑件在调节屏幕亮度,调节视频播放进度、调节音量等等地方都可以用到。可以参考Flutter material的Slider

那么手写体滑件该怎么实现呢,在这里我们的思路是:隐藏Flutter Slider自己的进度条直线和当前位置的实心圆,通过设置activeColorinactiveColor的颜色为透明色即可,这样我们就可以用手写体直线和圆来覆盖当前位置,达到了UI的手写体样式。如何覆盖?用Stack布局。

@override
Widget build(BuildContext context) {
return Stack(
  alignment: Alignment.center,
  children: [
	// 覆盖Slider的直线
	SizedBox(
	  height: 1,
	  width: double.infinity,
	  child: WiredCanvas(
		painter: WiredLineBase(
		  x1: 0,
		  y1: 0,
		  x2: double.infinity,
		  y2: 0,
		  strokeWidth: 2,
		),
		fillerType: RoughFilter.HatchFiller,
	  ),
	),
	// 覆盖Slider的的位置圆圈
	Positioned(
	  left: _getSliderWidth() * _currentSliderValue / widget.max - 12,
	  child: SizedBox(
		height: 24.0,
		width: 24.0,
		child: WiredCanvas(
		  painter: WiredCircleBase(
			diameterRatio: .7,
			fillColor: textColor,
		  ),
		  fillerType: RoughFilter.HachureFiller,
		  fillerConfig: FillerConfig.build(hachureGap: 1.0),
		),
	  ),
	),
	SliderTheme(
	  data: SliderThemeData(
		trackShape: CustomTrackShape(),
	  ),
	  child: Slider(
		value: _currentSliderValue,
		min: widget.min,
		max: widget.max,
		activeColor: Colors.transparent,
		inactiveColor: Colors.transparent,
		divisions: widget.divisions,
		label: widget.label,
		onChanged: (value) {
		  bool result = false;
		  if (widget.onChanged != null) {
			result = widget.onChanged!(value);
		  }

		  if (result) {
			setState(() {
			  _currentSliderValue = value;
			});
		  }
		},
	  ),
	),
  ],
);
}

以上代码,我们使用了Stack布局并指定alignmentcenter,达到覆盖原有的Slider的效果。

因为Flutter Slider有一个默认的margins,但是我们并不想这样,我们覆盖的直线需要从头覆盖到尾部,如果有了margins就会导致覆盖的直线长于Flutter Slider,所以使用了SliderTheme包裹了Slider,然后自定义实现data,为什么这么做可以去掉margins?请参考此处

我们绘制了手写体UI,但是Slider是可以改变他的value的,换句话说,Slider的那个实心圆是可以改变位置的,那么我们绘制的圆圈也要在拖动的时候跟着改变到正确的位置。从源码中看到可以按照比例来改变,我们知道当前Slider的值_currentSliderValue,我们也知道Slider的滑动最大值widget.max,这个时候如果知道Slider的物理像素值sliderWidth,我们就可以按照比例来计算出Slider的实心圆的位置x = sliderWidth * _currentSliderValue / widget.max,幸运的是我们可以通过下面的方法拿到sliderWidth

double _getSliderWidth() {
double width = 0;
try {
  var box = context.findRenderObject() as RenderBox;
  width = box.size.width;
} catch (e) {}

return width;
}

有的同学会问了,看源码为什么还要减去12?因为实心球的直径是24!

等等!源码里面在initState方法为甚还有这么一段代码?如注释描述,第一次进入build方法拿不到sliderWidth,所以我们需要在第一次build完毕在调用setState方法强制让widgetbuild一次,从而拿到sliderWidth让初始化时实心球的位置也能正确展示。

// Delay for calculate the slider's width `_getSliderWidth()` during the next frame
Future.delayed(Duration(milliseconds: 0), () {
  setState(() {});
});

结尾

我们在这里主要挑选了具有代表性的2个widgets - wired_button和wired_slider介绍了一下,其他的目前已实现的widgets基本差不多,主要思路是去除已有的边框或者线条,用手写体来覆盖已有的UI,从而代码UI样式的改变。源码在此pub.dev在此,欢迎大家提PR或者issues,如果对你有帮助希望能够给个star,谢谢!!!

上一篇:CF600 B. Queries about less or equal elements


下一篇:Leetcode 203: Remove Linked List Elements