GitHub地址:github.com/yumi0629/Fl…
(寫的比較急,程式碼還沒整理好,很凌亂,emmm,果然還是元旦之後再整理吧,→_→)
(本方案暫時只支援數字,不支援英文字母、中文等)
國際慣例先上效果圖:
需求分析
這個驗證碼輸入框的需求來源近日日群裡有人提出了這麼一個問題:像下面這種的控制元件該怎麼寫?
乍一看這就是一個TextField,但似乎又有那麼點不太一樣?我冷靜思考了一下,腦子裡有兩套解決方案:
- 1、複製一份TextField,魔改SDK。
- 2、用4個輸入框組合;
這兩種方案,看著就覺得腦殼疼啊。
先說第一種,點進原始碼我們可以看到TextField的實現鏈式關係為:TextField——>EditableText——>_Editable——>RenderEditable
,而主要的繪製都集中在了RenderEditable
中的paint()
方法:
@override
void paint(PaintingContext context, Offset offset) {
······
if (_hasVisualOverflow)
context.pushClipRect(needsCompositing, offset, Offset.zero & size, _paintContents);
else
// 具體繪製內容,包括cursor和文字
_paintContents(context, offset);
}
void _paintContents(PaintingContext context, Offset offset) {
······
// 繪製cursor
if (_selection.isCollapsed && _showCursor.value && cursorColor != null) {
_paintCaret(context.canvas, effectiveOffset);
} else if (!_selection.isCollapsed && _selectionColor != null) {
······
_paintSelection(context.canvas, effectiveOffset);
}
// 繪製文字
_textPainter.paint(context.canvas, effectiveOffset);
}
複製程式碼
雖說生命不息,魔改不止,但是這一套魔改下來,emmmm,我選擇拒絕!
再說第二種,4個控制元件組合,這種方式在佈局上確實會簡單很多,但是,致命的問題在於,要自己處理使用者的手勢輸入,以及cursor的位置移動等等,這個過程是十分複雜的,而且容易出錯。
眾所周知,我小拉麵是一個懶人,能走對角線的我絕對不拐彎,寫程式碼信奉“曲線救國”原則,怎麼簡單怎麼來,上面兩種方案明顯不適合我。
那麼怎麼辦呢?我凝視這設計稿,emmm,這果然還是一個TextField嘛,何必搞這麼複雜嘛。你不信的話,我加幾筆給你分析下:
letterSpace
,用來做數字間距很方便;TextField
自帶直線的UnderlineInputBorder
,那我們換成虛線的不就行了?虛線的dash值就是字型寬度和letterSpace交替。這個方案,絕對比上面兩個要簡單的多得多得多,嗯,非常適合我。好了,那麼我們先來解決第一個問題,測量字型寬度。
測量字型寬度
字型寬度的測量一直都是一個痛點,因為,它跟textSize肯定不相等,真的不好量啊······字型大小為textSize時,字型寬度並不是下圖中的藍色框,而是紅色框。
我下面要說的測量方案也只適合數字和英文字母,如果是中文,這個測量值和Flutter實際繪製的寬度還是有差距的。我們點進
RenderEditable
的原始碼可以發現,TextField
中字型的繪製最終是通過TextPainter
來完成的,而TextPainter
的繪製核心則是canvas.drawParagraph(_paragraph, offset);
,所以Paragraph
就是確定文字位置的最重要的類之一。Paragraph
中有一個minIntrinsicWidth
,這個值就是我們需要的文字寬度。Paragraph
可以通過ParagraphBuilder
來建立,ParagraphBuilder
可以接收一個ParagraphStyle
,其中包含了字型樣式、字型型別、字型方向等等各種資訊。至於minIntrinsicWidth
何時生效,原始碼文件中寫得很清楚,Valid only after [layout] has been called.
,所以我們layout之後就可以拿到minIntrinsicWidth
啦:
double calcTrueTextSize(double textSize) {
// 測量單個數字實際長度
var paragraph = ui.ParagraphBuilder(ui.ParagraphStyle(fontSize: textSize))
..addText("0");
var p = paragraph.build()
..layout(ui.ParagraphConstraints(width: double.infinity));
return p.minIntrinsicWidth;
}
複製程式碼
上面的程式碼就是測量數字“0”的方法,在Flutter預設數字字型中,0~9這十個數字所佔的實際繪製寬度都是一樣的,因此我們測量數字“0”就是測量了所有數字。但是,如果換成英文,那就不一樣了,英文的a~z這26個字母,即使都是小寫,測量出來的寬度也是每個字母都不一樣的,所以是沒法用在TextField上面的,因為我們沒法事先知曉使用者會輸入哪個字母。而至於中文,emmm,就比較坑了,測量值跟實際繪製的寬度完全不一樣,會小一點。
繪製UnderlineInputBorder
自定義一個UnderlineInputBorder十分簡單,繼承一下然後重寫paint()
方法即可:
@override
void paint(
Canvas canvas,
Rect rect, {
double gapStart,
double gapExtent = 0.0,
double gapPercentage = 0.0,
TextDirection textDirection,
}) {
Path path = Path();
path.moveTo(rect.bottomLeft.dx , rect.bottomLeft.dy);
path.lineTo(rect.bottomLeft.dx + (textWidth + spaceWidth) * textLength,
rect.bottomRight.dy);
path = dashPath.dashPath(path,
dashArray: dashPath.CircularIntervalList<double>([
textWidth,
spaceWidth,
]));
canvas.drawPath(path, borderSide.toPaint());
}
複製程式碼
父的paint()
方法會給我們一個rect
,這個值就是我們border的可繪製區域。Flutter預設不支援虛線,我們可以藉助一下別人寫好的工具 dash_path.dart,dashPath()
會返回給我們一個虛線Path
,這個工具類跟方便,走過路過不要錯過,建議收藏。
TextField
部分程式碼如下:
var underLineBorder = CustomUnderlineInputBorder(
spaceWidth: 30.0,
textWidth: calcTrueTextSize(50.0),
textLength: 4,
borderSide: BorderSide(color: Colors.black26, width: 2.0));
TextField(
maxLength: 4,
keyboardType: TextInputType.number,
style: TextStyle(
fontSize: 50.0,
color: Colors.black87,
letterSpacing: 30.0),
decoration: InputDecoration(
hintText: ' Please input verification code',
hintStyle: TextStyle(fontSize: 14.0, letterSpacing: 0.0),
enabledBorder: underLineBorder,
focusedBorder: underLineBorder),
);
複製程式碼
執行一下程式碼,你會發現,樣式還是有點差異,border整體向左偏移了:
這是因為新增了letterSpacing
屬性後,TextField
的第一個字元左邊會空出一半的letterSpacing
的距離,所以我們在繪製border的時候將左起點往右偏移一段距離即可:
// startOffset = letterSpacing*0.5
path.moveTo(rect.bottomLeft.dx + startOffset, rect.bottomLeft.dy);
複製程式碼
到此為止,我們就,畫好啦~~~哈哈哈是不是真的超級簡單呀~~~
自定義任意Border
既然Border可以在paint()
中隨心所欲地想怎麼畫就怎麼畫,那麼,理論上我們可以繪製任意樣式的Border。
比如畫個方框:
@override
void paint(
Canvas canvas,
Rect rect, {
double gapStart,
double gapExtent = 0.0,
double gapPercentage = 0.0,
TextDirection textDirection,
}) {
double curStartX = rect.left + startOffset - offsetX;
for (int i = 0; i < textLength; i++) {
Rect r = Rect.fromLTWH(curStartX, rect.top + offsetY,
textWidth + offsetX * 2, rect.height - offsetY * 2);
canvas.drawRect(r, borderSide.toPaint());
curStartX += (textWidth + spaceWidth);
}
}
複製程式碼
比如畫個愛心:
@override
void paint(
Canvas canvas,
Rect rect, {
double gapStart,
double gapExtent = 0.0,
double gapPercentage = 0.0,
TextDirection textDirection,
}) {
double width = rect.height - offsetX;
double radius = width * 0.25;
// 1:editable.dart _kCaretGap
double curStartX = startOffset - radius - offsetX - 1;
print(
'rect.height:${rect.height},curStartX:$curStartX,offsetX:$offsetX,startOffset:$startOffset');
if (curStartX < 0) {
throw ArgumentError(
'No enough space to paint border! LetterSpace is too small.');
}
double top = rect.center.dy - radius * 2;
double bottom = rect.center.dy + radius * 2;
Path path = Path();
for (int i = 0; i < textLength; i++) {
path.moveTo(curStartX + radius * 2, top + radius);
path.arcTo(
Rect.fromCircle(
center: Offset(curStartX + radius, top + radius), radius: radius),
degToRad(180.0 - angleOffset),
degToRad(180.0 + angleOffset),
true);
double sinLength = radius * sin(degToRad(angleOffset));
double cosLength = radius * cos(degToRad(angleOffset));
path.moveTo(curStartX + radius - cosLength, top + radius + sinLength);
path.lineTo(curStartX + radius * 2, bottom);
path.lineTo(curStartX + radius * 3 + cosLength, top + radius + sinLength);
path.arcTo(
Rect.fromCircle(
center: Offset(curStartX + radius * 3, top + radius),
radius: radius),
degToRad(angleOffset),
degToRad(-180.0 - angleOffset),
true);
curStartX += (textWidth + spaceWidth);
}
canvas.drawPath(path, borderSide.toPaint());
}
複製程式碼
甚至畫個背景圖:
@override
void paint(
Canvas canvas,
Rect rect, {
double gapStart,
double gapExtent = 0.0,
double gapPercentage = 0.0,
TextDirection textDirection,
}) {
double curStartX = rect.left;
for (int i = 0; i < textLength; i++) {
canvas.drawImage(image, Offset(curStartX, 0.0), Paint());
curStartX += (textWidth + spaceWidth);
}
}
複製程式碼
碎碎念
-
為什麼是繼承自
UnderlineInputBorder
,而不是InputBorder
?
直接繼承自InputBorder
需要重寫一大推方法,getInnerPath()
、getOuterPath()
等等,沒必要重新計算,直接拿UnderlineInputBorder
中算好的值就可以了。 -
為什麼明明是方框的border,卻不是繼承自
OutlineInputBorder
呢?
其實只是為了計算統一,因為UnderlineInputBorder
和OutlineInputBorder
傳遞給子的rect
引數會有所不同,所以如果你繼承自OutlineInputBorder
也是很OK的。