前言
本篇記錄的是使用Flutter完成手勢密碼的功能,大致效果如下圖所示:
該手勢密碼的功能比較簡單,下面會詳細記錄實現的過程,另外還會簡單說明如何將該手勢密碼作為外掛釋出到pub倉庫。
開始
實現上面的手勢密碼並不難,大致可以拆分成如下幾部分來完成:
-
繪製9個圓點
-
繪製手指滑動的線路
-
合併以上兩個部分
繪製圓點
我們使用物件導向的方式來處理9個圓點的繪製,每個圓點作為一個GesturePoint
類,這個類要提供一個圓心座標和半徑才能畫出圓形來,這裡先放上這個類的原始碼:
// point.dart
import 'package:flutter/material.dart';
import 'dart:math';
// 手勢密碼盤上的圓點
class GesturePoint {
// 中心實心圓點的畫筆
static final pointPainter = Paint()
..style = PaintingStyle.fill
..color = Colors.blue;
// 外層圓環的畫筆
static final linePainter = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.2
..color = Colors.blue;
// 圓點索引,0-9
final int index;
// 圓心座標
final double centerX;
final double centerY;
// 中心實心圓點的半徑
final double radius = 4;
// 外層空心的圓環半徑
final double padding = 26;
GesturePoint(this.index, this.centerX, this.centerY);
// 繪製小圓點
void drawCircle(Canvas canvas) {
// 繪製中心實心的圓點
canvas.drawOval(
Rect.fromCircle(center: Offset(centerX, centerY), radius: radius),
pointPainter);
// 繪製外層的圓環
canvas.drawOval(
Rect.fromCircle(center: Offset(centerX, centerY), radius: padding),
linePainter);
}
// 判斷座標是否在小圓內(padding為半徑)
// 該方法用於在手指滑動時做判斷,一旦座標處於圓點內部,則認為選中該圓點
bool checkInside(double x, double y) {
var distance = sqrt(pow((x - centerX), 2) + pow((y - centerY), 2));
return distance <= padding;
}
// 提供比較方法,用於判斷List中是否存在某個點
// 這個方法會在後面用到,當手勢滑動到某個點時,如果之前滑動到過這個點,則這個點不能再被選中
@override
bool operator ==(Object other) {
if (other is GesturePoint) {
return this.index == other.index &&
this.centerX == other.centerX &&
this.centerY == other.centerY;
}
return false;
}
// 複寫==方法時必須同時複寫hashCode方法
@override
int get hashCode => super.hashCode;
}
複製程式碼
上面需要注意的是,GesturePoint
類提供了一個drawCircle
方法用於繪製自身,將會在後面的程式碼中用到。
有了圓點這個物件,我們還需要將9個圓點依次畫在螢幕上,由於這9個圓點後續是不再更新的,所以使用一個StatelessWidget
即可。(如果你需要做成手指滑動到某個圓點,該圓點變色的效果,則需要用StatefulWidget元件去更新狀態。)
下面使用一個自定義的無狀態元件去畫這9個圓點,程式碼如下:
// panel.dart
import 'package:flutter/material.dart';
import 'package:flutter_gesture_password/point.dart';
// 9個圓點檢視
class GestureDotsPanel extends StatelessWidget {
// 表示圓點盤的寬高
final double width, height;
// 裝載9個圓點的集合,從外部傳入
final List<GesturePoint> points;
// 構造方法
GestureDotsPanel(this.width, this.height, this.points);
@override
Widget build(BuildContext context) {
return Container(
width: width,
height: height,
child: CustomPaint(
painter: _PanelPainter(points),
),
);
}
}
// 自定義的Painter,用於從圓點集合中遍歷所有圓點並依次畫出
class _PanelPainter extends CustomPainter {
final List<GesturePoint> points;
_PanelPainter(this.points);
@override
void paint(Canvas canvas, Size size) {
if (points.isNotEmpty) {
for (var p in points) {
// 畫出所有的圓點
p.drawCircle(canvas);
}
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false; // 不讓更新
}
複製程式碼
以上程式碼比較簡單,就不做詳細說明了,如果對Flutter繪圖基礎還不瞭解的同學,可以看看這裡的介紹:《Flutter實戰——自繪元件 (CustomPaint與Canvas)》
繪製手勢路徑
之所以手勢路徑要單獨拿出來繪製,沒有跟上面的9個小圓點盤放一起,是因為我們的圓點盤是不更新的,而手勢路徑需要在手指的每一次滑動中更新,所以單獨將手勢路徑作為一個元件。顯然這個元件是一個有狀態的元件,需要繼承StatefulWidget
來實現。
在開始編碼前,我們需要分析手勢滑動的流程:
-
必須監聽手指按下,手指滑動,手指抬起三種不同的事件
-
手指按下時,如果不在9個圓點中的任意一個上面,則手指滑動是無效的
-
手指按下時若在某個點上,則後面手指移動時,需要繪製從那個點到手指當前的一條直線,若手指移動過程中進入其他圓點,則需要先繪製之前手指經過的所有圓點間的直線,再繪製最後一個圓點到手指當前滑動的座標間的直線
-
每個圓點只允許被記錄一次,若之前手指滑動經過某個點,後面手指再經過該點時,該點不應該被記錄
-
手指抬起後,需要計算手指移動過程中經過了哪些點,以陣列的形式返回所有點的索引。且手指抬起後,不需要繪製最後一個點到手指抬起時的座標間的直線
梳理了上面的手勢密碼繪製流程後,我們還需要了解Flutter處理手勢的一些API,本例子中主要使用的GestureDetector
,這是Flutter官方對移動端手勢封裝的一個Widget,使用起來非常方便,如果有不太瞭解的同學,可以參考這裡——《Flutter實戰——手勢識別》
下面放上繪製手勢密碼路徑的所有程式碼:
// path.dart
import 'package:flutter/material.dart';
import 'package:flutter_gesture_password/gesture_view.dart';
import 'package:flutter_gesture_password/point.dart';
// 手勢密碼路徑檢視
class GesturePathView extends StatefulWidget {
// 手勢密碼路徑檢視的寬高,需要跟圓點檢視保持一致,由構造方法傳入
final double width;
final double height;
// 手勢密碼中的9個點,由構造方法傳入
final List<GesturePoint> points;
// 手勢密碼監聽器,用於在手指抬起時觸發,其定義為:typedef OnGestureCompleteListener = void Function(List<int>);
final OnGestureCompleteListener listener;
// 構造方法
GesturePathView(this.width, this.height, this.points, this.listener);
@override
State<StatefulWidget> createState() => _GesturePathViewState();
}
class _GesturePathViewState extends State<GesturePathView> {
// 記錄手指按下或者滑動過程中,經過的最後一個點
GesturePoint? lastPoint;
// 記錄手指滑動時的座標
Offset? movePos;
// 記錄手指滑動過程中所有經過的點
List<GesturePoint> pathPoints = [];
@override
Widget build(BuildContext context) {
return GestureDetector(
child: CustomPaint(
size: Size(widget.width, widget.height), // 指定元件大小
painter: _PathPainter(movePos, pathPoints), // 指定元件的繪製者,當movePos或者pathPoints更新時,整個元件也需要更新
),
onPanDown: _onPanDown, // 手指按下
onPanUpdate: _onPanUpdate, // 手指滑動
onPanEnd: _onPanEnd, // 手指抬起
);
}
// 手指按下
_onPanDown(DragDownDetails e) {
// 判斷按下的座標是否在某個點上
// 注意:e.localPosition表示的座標為相對整個元件的座標
// e.globalPosition表示的座標為相對整個螢幕的座標
final x = e.localPosition.dx;
final y = e.localPosition.dy;
// 判斷是否按在某個點上
for (var p in widget.points) {
if (p.checkInside(x, y)) {
lastPoint = p;
}
}
// 重置pathPoints
pathPoints.clear();
}
// 手指滑動
_onPanUpdate(DragUpdateDetails e) {
// 如果手指按下時不在某個圓點上,則不處理滑動事件
if (lastPoint == null) {
return;
}
// 滑動時如果在某個圓點上,則將該圓點加入路徑中
final x = e.localPosition.dx;
final y = e.localPosition.dy;
// passPoint代表手指滑動時是否經過某個點,可為空
GesturePoint? passPoint;
for (var p in widget.points) {
// 如果手指滑動經過某個點,且這個點之前沒有經過,則記錄下這個點
if (p.checkInside(x, y) && !pathPoints.contains(p)) {
passPoint = p;
break;
}
}
setState(() {
// 如果經過點部為空,則需要重新整理lastPoint和pathPoints,觸發整個元件的更新
if (passPoint != null) {
lastPoint = passPoint;
pathPoints.add(passPoint);
}
// 更新movePos的值
movePos = Offset(x, y);
});
}
// 手指抬起
_onPanEnd(DragEndDetails e) {
setState(() {
// 將movePos設定為空,防止畫出最後一個點到手指抬起時的座標間的直線
movePos = null;
});
// 呼叫Listener,返回手勢經過的所有點
List<int> arr = [];
if (pathPoints.isNotEmpty) {
for (var value in pathPoints) {
arr.add(value.index);
}
}
widget.listener(arr);
}
}
// 繪製手勢路徑
class _PathPainter extends CustomPainter {
// 手指當前的座標
final Offset? movePos;
// 手指經過點集合
final List<GesturePoint> pathPoints;
// 路徑畫筆
final pathPainter = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 6
..strokeCap = StrokeCap.round
..color = Colors.blue;
_PathPainter(this.movePos, this.pathPoints);
@override
void paint(Canvas canvas, Size size) {
_drawPassPath(canvas);
_drawRTPath(canvas);
}
// 繪製手指一動過程中,經過的所有點之間的直線
_drawPassPath(Canvas canvas) {
if (pathPoints.length <= 1) {
return;
}
for (int i = 0; i < pathPoints.length - 1; i++) {
var start = pathPoints[i];
var end = pathPoints[i + 1];
canvas.drawLine(Offset(start.centerX, start.centerY),
Offset(end.centerX, end.centerY), pathPainter);
}
}
// 繪製實時的,最後一個經過點和當前手指座標間的直線
_drawRTPath(Canvas canvas) {
if (pathPoints.isNotEmpty && movePos != null) {
var lastPoint = pathPoints.last;
canvas.drawLine(Offset(lastPoint.centerX, lastPoint.centerY), movePos!, pathPainter);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
複製程式碼
組合9個圓點盤和手勢路徑
組合這兩個元件需要用到Stack
元件,程式碼比較簡單,直接上程式碼了:
import 'package:flutter/material.dart';
import 'package:flutter_gesture_password/path.dart';
import 'package:flutter_gesture_password/point.dart';
import 'package:flutter_gesture_password/panel.dart';
// 定義手勢密碼回撥監聽器
typedef OnGestureCompleteListener = void Function(List<int>);
class GestureView extends StatefulWidget {
final double width, height;
final OnGestureCompleteListener listener;
GestureView({required this.width, required this.height, required this.listener});
@override
State<StatefulWidget> createState() => _GestureViewState();
}
class _GestureViewState extends State<GestureView> {
List<GesturePoint> _points = [];
@override
void initState() {
super.initState();
// 計算9個圓點的位置座標
double deltaW = widget.width / 4;
double deltaH = widget.height / 4;
for (int row = 0; row < 3; row++) {
for (int col = 0; col < 3; col++) {
int index = row * 3 + col;
var p = GesturePoint(index, (col + 1) * deltaW, (row + 1) * deltaH);
_points.add(p);
}
}
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
GestureDotsPanel(widget.width, widget.height, _points),
GesturePathView(widget.width, widget.height, _points, widget.listener)
],
);
}
}
複製程式碼
手勢密碼元件的使用
到這裡,手勢密碼就開發完成了,使用起來也非常簡單,本文開篇的預覽圖使用的如下程式碼:
import 'package:flutter/material.dart';
import 'package:flutter_gesture_password/gesture_view.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Gesture password',
home: _Home(),
);
}
}
class _Home extends StatefulWidget {
@override
State<StatefulWidget> createState() => _HomeState();
}
class _HomeState extends State<_Home> {
List<int>? pathArr;
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
return Scaffold(
appBar: AppBar(
title: Text('Gesture password'),
),
body: Column(
children: [
GestureView(
width: screenWidth,
height: screenWidth,
listener: (arr) {
setState(() {
pathArr = arr;
});
},
),
Text("${pathArr == null ? '' : pathArr}")
],
),
);
}
}
複製程式碼
上傳自定義元件到pub倉庫
上傳自定義元件到Pub倉庫的流程不算很複雜,這裡先放上官方文件:dart.cn/tools/pub/p…
下面整理髮布外掛到pub倉庫的主要步驟:
- (這一步非必須但是建議)在github上新建一個專案,並將我們寫的程式碼push到該倉庫。(後面配置homepage時可以直接使用GitHub倉庫地址)
- 在專案根目錄下建立README.md檔案,在其中編寫對於專案的一些介紹,以及你編寫的外掛的用法
- 在專案根目錄下建立CHANGELOG.md檔案,記錄每個不同版本更新了什麼
- 在專案根目錄下新建一個LICENSE檔案,表明該外掛使用什麼開源協議
- 修改專案中的
pubspec.yaml
檔案,主要修改點有:homepage: 「填寫專案主頁地址,這裡可以直接用github倉庫地址」 publish_to: 'https://pub.dev' # 這個配置表示要把外掛釋出到哪裡 version: 0.0.2 # 外掛版本,每次更新記得修改這個version 複製程式碼
- 在專案根目錄下執行
dart pub publish
,首次執行會出現如下提示:
點選上面的連結會開啟瀏覽器,授權即可。授權通過後,控制檯會提示上傳完成等資訊。Package has 2 warnings.. Do you want to publish xxx 0.0.1 (y/N)? y Pub needs your authorization to upload packages on your behalf. In a web browser, go to https://accounts.google.com/o/oauth2/auth?access_type=offline&approval_prompt=force&response_type=code&client_id=8183068855108-8grd2eg9tjq9f38os6f1urbcvsq39u8n.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A55486&code_challenge=V1-sGcrLkXljXXpOyJdqf8BJfRzBcUQaH9G1m329_M&code_challenge_method=S2536&scope=openid+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email Then click "Allow access". Waiting for your authorization... 複製程式碼
後記
本篇記錄的Flutter手勢密碼已經上傳到pub倉庫,地址為:pub.dev/packages/fl…
該專案的原始碼已託管至GitHub:github.com/yubo725/flu…
如果大家覺得有幫助,請不吝給個Star支援一下。
手勢密碼的最基本的實現方式就是上面的過程了,在本例中我並未做過多的封裝,也沒有提供更多的配置項比如手勢密碼圓點顏色,路徑線條顏色、粗細等等,這些大家可以根據自己的專案,自行拷貝程式碼並做相應修改。另外,手勢密碼的儲存與校驗不在本篇記錄範圍內,大家可以根據最終的整型陣列來做一些加密之類並儲存到本地,在校驗密碼時,做字串匹配即可。