前の記事 に引き続き、仕事用に作ったUIパーツです。
実装したもの
画面端に表示して、アクセントとなるような半円形のボタンです。
標準のタップフィードバックとしては、TopとRightにある同心円が表示されるもの、
前の記事で書いた BounceAnimation
を組み合わせることで、タップ時にボタンが小さくなるフィードバックも可能です。
黒や白の枠は、想定したサイズで描画されているかを確認するために重ねています。
詳細は、コード全文を確認いただければと思います。
コード
全文はこちら。
コード説明
まず、実装イメージは下記のようになります。
class HalfCircleButtonScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('HalfCircleButton'),
),
backgroundColor: Colors.blueGrey,
body: Stack(
children: <Widget>[
_buildTop(context),
],
),
);
}
Widget _buildTop(BuildContext context) {
return Align(
alignment: Alignment.topCenter,
child: Container(
width: 324,
height: 162,
child: HalfCircleButton(
strokeWidth: 16,
direction: AxisDirection.up,
color: Colors.blue,
strokeColor: Colors.lightBlue,
highlightColor: Colors.yellow,
onTap: () {
print('tapped top button');
},
child: Center(
child: Text(
'Top button',
style: Theme.of(context).textTheme.display1.copyWith(
color: Colors.white,
),
),
),
),
),
);
}
}
Stack
+ Align
を利用することで、他のUIを無視して自由に画面端に表示できます。
ボタンの大きさについては、 HalfCircleButton
を囲む Container
で指定するのが良いかと思っています。
(Containerを固定値で表示したり、画面幅からマージンを確保してそれ以外の領域で表示したり)
HalfCircleButton
に各種パラメータを設定、child
としてボタンに乗せる要素を指定します。
次に、 HalfCircleButton
の実態を定義していきます。
class HalfCircleButton extends StatefulWidget {
HalfCircleButton({
this.strokeWidth,
this.tapHighlightScale = 1.1,
this.direction,
this.color,
this.strokeColor,
this.highlightColor,
this.onTap,
this.child,
});
final double strokeWidth;
final double tapHighlightScale;
final AxisDirection direction;
final Color color;
final Color strokeColor;
final Color highlightColor;
final GestureTapCallback onTap;
final Widget child;
@override
_HalfCircleButtonState createState() => _HalfCircleButtonState();
}
今回は、通常のボタンUIとはかけ離れているため、StatefulWidget
を継承しています。
class _HalfCircleButtonState extends State<HalfCircleButton> {
bool isHighlight = false;
void _showHighlight(TapDownDetails details) {
setState(() {
isHighlight = true;
});
// after few milliseconds, automatically hide highlight.
Timer(Duration(milliseconds: 200), () {
setState(() {
isHighlight = false;
});
});
}
void _hideHighlight() {
setState(() {
isHighlight = false;
});
}
今回は、ハイライト表示を AnimatedOpacity
で制御しているため、 bool
の isHighlight
を状態として管理しています。
このボタンでは、ユーザが長押ししていた場合にも200msec後にフィードバック部分が消え始めるようにしてみました。
ユーザが、押す→離すを高速に行うと、opacityが1になる前に0になり始めるので、そこは改善の余地があるかもしれないです。
@override
Widget build(BuildContext context) {
final alignment = _alignment();
return AspectRatio(
aspectRatio: widget.direction == AxisDirection.up ||
widget.direction == AxisDirection.down
? 2 / 1
: 1 / 2,
build
メソッドが比較的長いので、細切れで。
まずは、 AspectRatio
を利用して、縦横比を固定化しています。
今回、「ボタンサイズは画面幅から左右24pt開けた領域を埋めるようにする」といったことも実現したかったので、指定しています。
child: Stack(
children: <Widget>[
// tap highlight
AnimatedOpacity(
opacity: isHighlight ? 1 : 0,
duration: Duration(milliseconds: 200),
child: Transform.scale(
scale: widget.tapHighlightScale,
alignment: alignment,
child: CustomPaint(
painter: HalfCirclePainter(
color: widget.highlightColor,
direction: widget.direction,
),
child: Container(),
),
),
),
Stack
の最下層には、ハイライト表示部分を配置しています。
外部から与えられた「ボタンのサイズ」を超えて表示したかったので、 Transform.scale
を利用しています。
HalfCirclePainter
が、半円を描画している部分です。後述します。
// button area
GestureDetector(
onTap: () {
_hideHighlight();
widget.onTap();
},
onTapDown: _showHighlight,
onTapCancel: _hideHighlight,
behavior: HitTestBehavior.deferToChild,
child: Stack(
alignment: alignment,
children: <Widget>[
// outer circle
CustomPaint(
painter: HalfCirclePainter(
color: widget.strokeColor,
direction: widget.direction,
),
child: Container(),
),
// inner circle
Container(
padding: _edgeInsets(),
alignment: alignment,
child: CustomPaint(
painter: HalfCirclePainter(
color: widget.color,
direction: widget.direction,
),
child: Container(),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
bottom: 0,
child: widget.child ?? Container(),
),
],
),
),
],
),
);
}
ボタン部分です。
borderをつけたかったのですが、Paintのstrokeを使うと計算が難しかったため、単純に2つの大きさの異なる半円を重ねています。
(今気づいたのですが、ボタンを半透明にしたい場合は、意図しない表示になってしまいそうですね。。。)
Alignment _alignment() {
switch (widget.direction) {
case AxisDirection.up:
return Alignment.topCenter;
case AxisDirection.right:
return Alignment.centerRight;
case AxisDirection.down:
return Alignment.bottomCenter;
case AxisDirection.left:
return Alignment.centerLeft;
}
return null;
}
EdgeInsets _edgeInsets() {
switch (widget.direction) {
case AxisDirection.up:
return EdgeInsets.symmetric(horizontal: widget.strokeWidth) +
EdgeInsets.only(bottom: widget.strokeWidth);
case AxisDirection.right:
return EdgeInsets.symmetric(vertical: widget.strokeWidth) +
EdgeInsets.only(left: widget.strokeWidth);
case AxisDirection.down:
return EdgeInsets.symmetric(horizontal: widget.strokeWidth) +
EdgeInsets.only(top: widget.strokeWidth);
case AxisDirection.left:
return EdgeInsets.symmetric(vertical: widget.strokeWidth) +
EdgeInsets.only(right: widget.strokeWidth);
}
return null;
}
}
build
内で利用していた、細かな変換・計算を行っているメソッドです。
_edgeInsets
については、向きごとに1箇所だけ 0
にする必要があるため、このように場合分けをして記述しています。
class HalfCirclePainter extends CustomPainter {
HalfCirclePainter({
@required this.color,
this.direction = AxisDirection.down,
});
final Color color;
final AxisDirection direction;
Path _path;
@override
void paint(Canvas canvas, Size size) {
_path = _getHalfCirclePath(size.width, size.height);
Paint paint = Paint()
..color = color
..style = PaintingStyle.fill;
canvas.drawPath(
_path,
paint,
);
}
CustomPainter
を継承した、 HalfCirclePainter
の定義です。
色と方向(円の見えている部分)を受け取って、Widgetの領域内に半円を描きます。
半円の指定については、後述する _getHalfCirclePath
が行っています。
@override
bool hitTest(Offset position) {
if (_path == null) {
return false;
}
return _path.contains(position);
}
半円の周り部分をタップしたときに反応するとかっこ悪いので、 hitTest
を実装し、
paint
のときに描画した _path
の枠内がタップされたのか?を判定しています。
Path _getHalfCirclePath(double x, double y) {
num degToRad(num deg) => deg * (math.pi / 180.0);
final offset = _getOffset(x, y);
final startAngle = _getStartAngle();
return Path()
..moveTo(offset.dx, offset.dy)
..arcTo(
_getRect(x, y),
degToRad(startAngle),
degToRad(180),
false,
);
}
degToRad
は、Stack Overflowから拾った気がするのですが、URLが見当たらず。。。
方向によって、始点・円弧の領域・円弧の開始角が違うので、そこは別メソッドで計算しています。
例えば下部ボタンであれば、左下から始めて、180度の方向から、180度分回す感じです。
@override
bool shouldRepaint(HalfCirclePainter oldDelegate) {
return oldDelegate.color != color || oldDelegate.direction != direction;
}
Offset _getOffset(double x, double y) {
switch (direction) {
case AxisDirection.up:
return Offset(x, 0);
case AxisDirection.right:
return Offset(x, y);
case AxisDirection.down:
return Offset(0, y);
case AxisDirection.left:
return Offset(0, 0);
}
return null;
}
num _getStartAngle() {
switch (direction) {
case AxisDirection.up:
return 0;
case AxisDirection.right:
return 90;
case AxisDirection.down:
return 180;
case AxisDirection.left:
return 270;
}
return null;
}
Rect _getRect(double x, double y) {
switch (direction) {
case AxisDirection.up:
return Rect.fromCenter(
center: Offset(x / 2, 0),
width: x,
height: y * 2,
);
case AxisDirection.right:
return Rect.fromCenter(
center: Offset(x, y / 2),
width: x * 2,
height: y,
);
case AxisDirection.down:
return Rect.fromCenter(
center: Offset(x / 2, y),
width: x,
height: y * 2,
);
case AxisDirection.left:
return Rect.fromCenter(
center: Offset(0, y / 2),
width: x * 2,
height: y,
);
}
return null;
}
}
指定された方向によって、それぞれの値を判定・計算しています。
終わり
CustomPainter
をうまく使えば、Dartのコード上で好きな図形を書くことができそうです。
とはいえ、ある程度難しい図形になる場合は、デザイナーさんにイラレで書いてもらいSVGで出力、flutter_svgで描画する形が良いのかと思います。
(とはいえ、これもかなり複雑になってくると、描画されないパターンもありました。。公式のSVGサポートがほしいです。。)