Thông thường, để tạo widget trong Flutter, mình phải thông qua composition (tree lồng nhau), nghĩa là kết hợp một số widget cơ bản tạo thành một widget phức tạp hơn.
Khi không thể kết hợp các widget Flutter hiện có, bạn có thể sử dụng widget CustomPaint
để vẽ chính xác những gì bạn muốn. Đó không phải là nội dung của bài viết này, nhưng bạn có thể xem một số ví dụ điển hình ở đây.
Khi "khám phá" source code Flutter, bạn sẽ phát hiện ra rằng phần lớn các widget không sử dụng composition hay CustomPaint
. Thay vào đó, chúng sử dụng RenderObject
hoặc các subclass khác nhau, đặc biệt là RenderBox
. Tạo một widget tùy chỉnh theo cách này sẽ giúp bạn vẽ và định kích thước cho widget linh hoạt nhưng cũng rất phức tạp, đó là lý do bài viết này ra đời.
Nếu đây là lần đầu tiên bạn tạo một widget tùy chỉnh, có lẽ bạn nên thử các phương pháp custom painting được đề cập ở trên trước. Nhưng nếu bạn dám đi từ low-level (lập trình viên viết code phần cứng, sử dụng ngôn ngữ lập trình bậc thấp) thì chúng tôi vô cùng hoan nghênh bạn. Bạn sẽ học được rất nhiều bằng cách làm theo từng bước trong hướng dẫn.
Code trong bài viết này được cập nhật cho Flutter 2.0.0 và Dart 2.12.
Tổng quan
Tạo một custom render object bao gồm các khía cạnh sau:
- Widget: là giao diện hiển thị để show với thế giới bên ngoài, nó bao gồm cả các thuộc tính mà bạn muốn khi sử dụng. Bạn sử dụng các thuộc tính này để tạo hoặc cập nhật render object.
- Children: render object hoặc widget có thể có 0, một hoặc nhiều children. Số lượng children sẽ ảnh hưởng đến kích thước và layout của render object. Trong bài viết này, bạn sẽ tạo một render object không có children. Điều này sẽ giúp đơn giản hóa một số bước.
- Layout: Trong Flutter, sự ràng buộc (constraints go down) giảm xuống, vì vậy bạn được cấp chiều rộng và chiều dài max và min từ parent và sau đó sẽ "báo cáo" lại kích thước mà bạn muốn có khi sử dụng. Bạn sẽ wrap nội dung của mình hay cố gắng mở rộng hết mức kích thước mà parent cho phép? Một phần của việc tạo render object là cung cấp thông tin về kích thước bên trong (intrinsic size). Vì widget bạn tạo ra trong bài này sẽ không có bất kỳ children nào, bạn không cần lo về việc "hỏi" các widget con muốn độ lớn như thế nào hoặc xác định vị trí hiển thị của chúng. Tuy nhiên, bạn sẽ cần quy định kích thước mà bạn cần để paint nội dung của mình.
- Painting: Bước này tương tự như những gì bạn sẽ làm với widget
CustomPaint
. Bạn sẽ nhận được một canvas để bạn có thể vẽ những thứ mong muốn lên đó. - Hit testing: bạn cho Flutter biết những thứ mà bạn sẽ xử lý đối với các sự kiện touch. Trong bài viết này, bạn sẽ thêm gesture detector để giúp bạn xử lý các sự kiện touch.
- Semantics: bổ sung thông tin về text widget của bạn. Bạn sẽ không thấy text này, nhưng Flutter có thể sử dụng để giúp người dùng khiếm thị. Nếu bạn không biết bất kỳ ai bị mù, thì bạn nên bỏ qua bước này.
Được rồi. Bây giờ chúng ta sẽ bắt đầu ngay.
Preview Widget
Widget mà bạn tạo ra sẽ trông như thế này:
Lưu ý: Bạn thắc mắc tại sao tôi không sử dụng Slider widget luôn và bỏ qua mọi rắc rối khi tạo custom render object không? Lý do là tôi muốn tạo progress bar của audio player hiển thị khi phát nhạc. Slider mặc định không làm được điều đó, mặc dù có thể hack RangeSlider vào một thứ gì đó. Người ta có thể sử dụng CustomPaint để làm, nhưng nó không đủ linh hoạt. Dù sao, với render object cơ bản ở trên, sẽ rất đơn giản để truyền thêm một vài thuộc tính và cập nhật phương thức paint
để ta có thể tạo progress bar của audio player. Bên cạnh đó, đây là cơ hội để cả bạn và tôi tìm hiểu về cách tạo các render object của riêng mình.
Nếu bạn bị lạc trên đường đi, hãy cuộn xuống cuối bài viết này, nơi bạn sẽ tìm thấy full code.
Khởi tạo Widget
Bước đầu tiên là tạo một widget cho phép bạn truyền các thuộc tính mong muốn vào render object của bạn. Hãy bắt đầu với ba tùy chọn sau:
- progress bar color.
- thumb color.
- thumb size.
Note: Tôi thực sự không biết tại sao thumb được gọi là thumb, nhưng đó là cách mà Flutter Slider gọi nó, vì vậy tôi sử dụng cùng tên luôn. Hãy nghĩ nó giống như một tay cầm hoặc một núm vặn. Một tên khác của progress bar là track bar hoặc seek bar (thanh tìm kiếm).
Hiểu các phần chính của widget
Trước khi tạo widget, hãy xem sơ lược cấu trúc của nó:
class ProgressBar extends LeafRenderObjectWidget {
@override
RenderProgressBar createRenderObject(...) {}
@override
void updateRenderObject(...) {}
@override
void debugFillProperties(...) {}
}
Ghi chú:
- Widget của bạn sẽ extend
LeafRenderObjectWidget
vì nó không có bất kỳ children nào. Nếu bạn đang tạo một render object với một child, bạn sẽ sử dụngSingleChildRenderObjectWidget
và đối với nhiều children, bạn sẽ sử dụngMultiChildRenderObjectWidget
. - Flutter framework (chỉ các element) sẽ gọi
createRenderObject
khi nó muốn tạo render object liên kết với widget này. Vì chúng ta đặt tên widget làProgressBar
, nên theo thói quen, chúng ta đặt tiền tố này bằng “Render” khi đặt tên cho render object. Đó là lý do tại sao bạn có giá trị trả về làRenderProgressBar
. Lưu ý rằng bạn chưa tạo class này. Đó là class render object mà bạn tạo sau. - Chi phí để tạo các widget không tốn kém là bao nhưng sẽ rất tốn kém nếu tạo lại các render object mỗi khi có bản cập nhật mới. Vì vậy, khi một thuộc tính của widget thay đổi, hệ thống sẽ gọi
updateRenderObject
, nơi bạn chỉ cần cập nhật các thuộc tính chung của render object của mình mà không cần tạo lại toàn bộ object. - Phương thức
debugFillProperties
cung cấp thông tin về các thuộc tính của class trong quá trình debugging, nhưng nó không cần thiết lắm đối với mục đích của bài viết này.
Tạo một tệp mới có tên là progress_bar.dart. Thêm code dưới đây vào. Đây chỉ là một phiên bản đầy đủ hơn những gì bạn đã thấy ở trên.
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
class ProgressBar extends LeafRenderObjectWidget {
const ProgressBar({
Key? key,
required this.barColor,
required this.thumbColor,
this.thumbSize = 20.0,
}) : super(key: key);
final Color barColor;
final Color thumbColor;
final double thumbSize;
@override
RenderProgressBar createRenderObject(BuildContext context) {
return RenderProgressBar(
barColor: barColor,
thumbColor: thumbColor,
thumbSize: thumbSize,
);
}
@override
void updateRenderObject(
BuildContext context, RenderProgressBar renderObject) {
renderObject
..barColor = barColor
..thumbColor = thumbColor
..thumbSize = thumbSize;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(ColorProperty('barColor', barColor));
properties.add(ColorProperty('thumbColor', thumbColor));
properties.add(DoubleProperty('thumbSize', thumbSize));
}
}
Bây giờ bỏ qua các lỗi về RenderProgressBar
. Bạn vẫn chưa tạo ra nó.
Trong phiên bản ProgressBar
đã điền đầy đủ thông tin này, bạn có thể thấy các thuộc tính barColor
, thumbColor
và thumbSize
được sử dụng theo những cách sau:
- Đầu tiên là mình sẽ gọi hàm khởi tạo.
- Tạo một instance mới của
RenderProgressBar
- Cập nhật instance hiện có của
RenderProgressBar
- Cung cấp debug information
Bây giờ bạn đã tạo ra widget ProgressBar
, đã đến lúc tạo class RenderProgressBar
.
Khởi tạo Render object
Trong phần này, bạn sẽ tạo class render object và thêm các thuộc tính bạn cần.
Khởi tạo class
Tạo một class mới có tên là RenderProgressBar
như hình dưới đây. Bạn có thể giữ nó trong cùng một tệp với widget ProgressBar
.
class RenderProgressBar extends RenderBox {
}
RenderBox
là một subclass của RenderObject
và có hệ tọa độ hai chiều (two-dimensional coordinate). Đó là, nó có chiều rộng và chiều cao.
Thêm hàm tạo
Thêm hàm tạo sau vào RenderProgressBar
:
RenderProgressBar({
required Color barColor,
required Color thumbColor,
required double thumbSize,
}) : _barColor = barColor,
_thumbColor = thumbColor,
_thumbSize = thumbSize;
Điều này xác định các thuộc tính public mà bạn muốn, nhưng bạn sẽ thấy một số lỗi vì chưa tạo thuộc tính private cho chúng. Tiếp theo bạn sẽ làm điều đó.
Thêm các thuộc tính
Thêm code cho barColor
:
Color get barColor => _barColor;
Color _barColor;
set barColor(Color value) {
if (_barColor == value)
return;
_barColor = value;
markNeedsPaint();
}
Để cập nhật màu sắc, bạn sẽ cần repaint thanh bằng màu mới. Việc gọi markNeedsPaint
ở cuối phương thức để framework gọi phương thức paint
. Vì việc painting có thể tốn kém, bạn chỉ nên gọi markNeedsPaint
khi cần thiết.
Bây giờ thêm code cho thumbColor
:
Color get thumbColor => _thumbColor;
Color _thumbColor;
set thumbColor(Color value) {
if (_thumbColor == value)
return;
_thumbColor = value;
markNeedsPaint();
}
Điều này hoạt động giống như barColor
đã làm.
Cuối cùng, thêm code cho biến thumbSize
:
double get thumbSize => _thumbSize;
double _thumbSize;
set thumbSize(double value) {
if (_thumbSize == value)
return;
_thumbSize = value;
markNeedsLayout();
}
Một điểm khác biệt ở đây là thay vì gọi markNeeds
Paint
, bây giờ bạn đang gọi markNeeds
Layout
. Đó là bởi vì việc thay đổi kích thước cũng sẽ ảnh hưởng đến kích thước của toàn bộ render object. Gọi markNeedsLayout
cho hệ thống biết để gọi tiếp phương thức layout
. Một lệnh gọi layout khác sẽ tự động dẫn đến việc repaint, vì vậy không cần gọi markNeedsPaint
bổ sung.
Layout and size
Nếu bạn sử dụng widget của mình thì nó sẽ trông như sau:
Scaffold(
body: Center(
child: Container(
color: Colors.white,
child: ProgressBar(
barColor: Colors.blue,
thumbColor: Colors.red,
thumbSize: 20.0,
),
),
),
),
IDE sẽ không báo lỗi (cho đến khi bạn chạy ứng dụng). Tuy nhiên, ngay cả khi bạn đã chạy ứng dụng thành công thì cũng sẽ không có gì để xem. Lý do là vì widget của bạn không có bất kỳ kích thước bên trong nào và bạn chưa vẽ bất kỳ nội dung nào cả. Trước tiên, hãy xử lý vấn đề kích thước.
Hãy xem lại thanh progress bar mà chúng ta muốn tạo:
Trên một màn hình thông thường, bạn có thể muốn chiều rộng tối đa của thiết bị. Nhưng đối với chiều cao, bạn muốn nó ôm lấy chiều cao của ta quy định.
Với thông tin đó, bạn có thể set kích thước mà mình mong muốn.
Thiết lập kích thước mong muốn
Phương thức computeDryLayout
giúp bạn tính toán độ lớn của widget dựa trên các ràng buộc đã quy định. Trước đây, điều này được thực hiện trong performLayout
, nhưng bây giờ bạn có thể đặt thêm các logic tính toán layout.
Thêm code sau vào RenderProgressBar
:
@override
void performLayout() {
size = computeDryLayout(constraints);
}
@override
Size computeDryLayout(BoxConstraints constraints) {
final desiredWidth = constraints.maxWidth;
final desiredHeight = thumbSize;
final desiredSize = Size(desiredWidth, desiredHeight);
return constraints.constrain(desiredSize);
}
Ghi chú:
- Nếu bạn cần kích thước của bất kỳ children nào, bạn có thể lấy chúng bằng cách gọi phương thức
getDryLayout
của child và truyền vào một số ràng buộc kích thước min và max. Điều này cung cấp cho bạn thông tin bạn cần để đặt các children và xác định kích thước của chúng. VìRenderProgressBar
của chúng ta không có bất kỳ children nào, tất cả những gì bạn cần làm ở đây là tính toán lại kích thước thôi. - Biến
constraints
có kiểuBoxContraints
là một thuộc tính củaRenderBox
. CácBoxContraints
này được truyền vào từ parent và cho bạn biết chiều rộng và chiều dài max và min mà bạn được phép sử dụng. Bạn có thể chọn bất kỳ kích thước nào cho mình trong giới hạn đó. Bằng cách chọnmaxWidth
, bạn đang nói rằng bạn muốn mở rộng để lớn hết cỡ cho phép. Để có chiều cao mong muốn, bạn sử dụng thuộc tínhthumbSize
. - Truyền kích thước mong muốn của bạn vào
constraints.constrain
đảm bảo rằng kích thước sử dụng vẫn nằm trong các ràng buộc cho phép. Ví dụ: nếuthumbSize
lớn, nó có thể vượt quáconstraints.maxHeight
từ parent, điều này không được phép. - Biến
size
cũng là một thuộc tính củaRenderBox
. Bạn chỉ nên đặt nó bên trong phương thứcperformLayout
. Ở mọi nơi khác, bạn nên gọimarkNeedsLayout
. Ngoài ra, phương thứccomputeDryLayout
không được thay đổi bất kỳ state nào.
Bây giờ bạn đã chính thức set kích thước cho render object của mình và parent render object cũng sẽ có thông tin đó.
Thiết lập intrinsic size
Bạn mong muốn widget của bạn có chiều rộng như thế nào?
Thêm bốn hàm sau vào RenderProgressBar
:
static const _minDesiredWidth = 100.0;
@override
double computeMinIntrinsicWidth(double height) => _minDesiredWidth;
@override
double computeMaxIntrinsicWidth(double height) => _minDesiredWidth;
@override
double computeMinIntrinsicHeight(double width) => thumbSize;
@override
double computeMaxIntrinsicHeight(double width) => thumbSize;
Ghi chú:
- Chiều rộng nội tại tối thiểu (min intrinsic width) là chiều rộng hẹp nhất mà widget mong muốn. Điều đó không đảm bảo rằng nó sẽ không bao giờ có chiều rộng nhỏ hơn, nhưng điều này nói lên răng là đây là chiều rộng nhỏ nhất của nó. Widget Flutter Slider sử dụng giá trị cứng là
144.0
(hoặc gấp ba lần mục tiêu cảm ứng (touch target) 48 pixel). Tôi nghĩ rằng chúng ta có thể thu hẹp hơn một chút với100.0
. - Khi set chiều rộng tối đa, bạn có thể đã sử dụng
double.infinity
, nhưng kích thước vô cực có phải là điều bạn muốn không? Nếu không, thì sử dụng chiều rộng min là hợp lý. Điều này tương tự như những gì widget Slider đã làm. - Tôi đã sử dụng các giá trị giống nhau cho chiều rộng max và min cũng như các giá trị tương tự cho chiều cao max và min. Đó là bởi vì chiều cao của progress bar không bị ảnh hưởng bởi các ràng buộc về chiều rộng. Tương tự, chiều rộng không bị ảnh hưởng bởi các ràng buộc về chiều cao. Như bạn có thể thấy trong hình động bên dưới, việc làm cho chiều rộng hẹp hơn khiến chiều cao của text cao hơn.
Để hiểu kích thước nội tại (intrinsic sizes), bạn sẽ thấy rất hữu ích khi thấy sự khác biệt giữa width
, minIntrinsicWidth
, và maxIntrinsicWidth
cho một Text
widget.
Hình ảnh dưới đây cho ta thấy chiều rộng được quy định bởi parent.
Hình ảnh sau đây cho ta thấy minIntrinsicWidth. Đây là nơi hẹp nhất mà widget này có thể có.
Cuối cùng, hình ảnh sau đây hiển thị maxIntrinsicWidth. Đây là kích thước rộng nhất mà widget này có thể có. Hai dòng đầu tiên kết thúc bằng \n
- ký tự xuống dòng. Tuy nhiên giới hạn width
do parent áp đặt, dòng thứ ba được soft-wrap để “bao bọc xung quanh (wraps around)” nên nó nằm trên dòng thứ tư. Nếu bạn muốn sử dụng toàn bộ chiều rộng, đó sẽ là giá trị của maxIntrinsicWidth.
Kiểm tra Widget
Widget chưa tự paint được nhưng nó đã có kích thước riêng cho nó. Chúng ta có thể bọc nó bằng một Container có màu để "thấy” nó.
Thay thế tệp main.dart của bạn bằng layout đơn giản sau:
import 'package:flutter/material.dart';
import 'progress_bar.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: Container(
color: Colors.cyan, // <-- colored Container
child: ProgressBar(
barColor: Colors.blue,
thumbColor: Colors.red,
thumbSize: 20.0,
),
),
),
),
);
}
}
Chạy nó và bạn sẽ thấy một thanh màu lục lam:
Vì Container
có cùng kích thước với ProgressBar
, chúng ta biết rằng kích thước đó đang hoạt động.
Painting
Tất cả các hành động để vẽ nội dung cho render object nằm trong phương thức paint
. Thêm code sau vào class RenderProgressBar
:
double _currentThumbValue = 0.5;
@override
void paint(PaintingContext context, Offset offset) {
final canvas = context.canvas;
canvas.save();
canvas.translate(offset.dx, offset.dy);
// paint bar
final barPaint = Paint()
..color = barColor
..strokeWidth = 5;
final point1 = Offset(0, size.height / 2);
final point2 = Offset(size.width, size.height / 2);
canvas.drawLine(point1, point2, barPaint);
// paint thumb
final thumbPaint = Paint()..color = thumbColor;
final thumbDx = _currentThumbValue * size.width;
final center = Offset(thumbDx, size.height / 2);
canvas.drawCircle(center, thumbSize / 2, thumbPaint);
canvas.restore();
}
Ghi chú:
- Chúng ta sẽ định nghĩa
_currentThumbValue
là một số từ0
đến1
đại diện cho vị trí thumb trên progress bar, trong đó0
có nghĩa là ở xa bên trái và1
có nghĩa là ở xa bên phải. Sử dụng0,5
để đặt nó ở giữa thanh. - Tham số
offset
của phương thứcpaint
cho bạn biết vị trí góc trên cùng bên trái của layout trên canvas. Lưu canvas và sau đó dịch sang vị trí đó có nghĩa là bạn không cần phải lo về offset này cho bất kỳ bản vẽ nào mà bạn sắp thực hiện. - Đầu tiên hãy vẽ thanh (bar) mà thumb di chuyển theo. Màu được lấy từ
barColor
- tham số widget. - Sau đó vẽ thumb. Nó được căn giữa theo chiều dọc và vị trí nằm ngang dựa trên
_currentThumbValue
. Khi giá trị đó thay đổi vàmarkNeedsPaint
được gọi, vị trí mới sẽ được repaint ở đây.
Trong main.dart, hãy bỏ đi màu của widget Container
parent:
child: Container(
// color: Colors.cyan, <-- comment this out
child: ProgressBar(
...
Bây giờ hãy chạy lại ứng dụng:
Tuyệt vời! Bạn có thể thấy nó ngay bây giờ!
Tuy nhiên, nó vẫn chưa có phần tương tác, vì vậy hãy xử lý ngay bây giờ.
Hit testing
Hit testing để Flutter biết bạn có muốn widget của mình xử lý các sự kiện touch hay không. Vì chúng ta muốn có thể di chuyển thumb trên progress bar của mình, chúng ta chắc chắn muốn render object để xử lý các sự kiện touch.
Thêm các import sau vào file Progress_bar.dart:
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
Và sau đó thêm code bên dưới vào RenderProgressBar
:
late HorizontalDragGestureRecognizer _drag;
@override
bool hitTestSelf(Offset position) => true;
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent) {
_drag.addPointer(event);
}
}
Ghi chú:
- Vì bạn muốn có thể di chuyển thumb theo chiều ngang dọc theo thanh bar,
HorizontalDragGestureRecognize
cho phép bạn nhận thông báo về các loại sự kiện touch này. - Trả về
true
tronghitTestSelf
để nói với Flutter rằng các sự kiện touch sẽ được widget này xử lý.hitTestSelf
cũng cung cấp một tham sốposition
, vì vậy về mặt lý thuyết, đôi khi bạn có thể trả vềtrue
và đôi khi trả vềfalse
dựa trênposition
của sự kiện touch. Điều này sẽ hữu ích nếu bạn có một widget hình bánh rán (donut-shaped). handleEvent
thêmPointerDownEvent
vào drag gesture recognizer, nhưng bạn vẫn cần khởi tạo nó và xử lý các sự kiện khác mà bạn sẽ thực hiện tiếp theo.
Xử lý với gesture recognizer
Thay thế hàm tạo RenderProgressBar
bằng code sau:
RenderProgressBar({
required Color barColor,
required Color thumbColor,
required double thumbSize,
}) : _barColor = barColor,
_thumbColor = thumbColor,
_thumbSize = thumbSize {
// initialize the gesture recognizer
_drag = HorizontalDragGestureRecognizer()
..onStart = (DragStartDetails details) {
_updateThumbPosition(details.localPosition);
}
..onUpdate = (DragUpdateDetails details) {
_updateThumbPosition(details.localPosition);
};
}
Và sau đó thêm phương thức _updateThumbPosition
:
void _updateThumbPosition(Offset localPosition) {
var dx = localPosition.dx.clamp(0, size.width);
_currentThumbValue = dx / size.width;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
Ghi chú:
localPosition
là vị trí touch của sự kiện drag liên quan đến góc trên cùng bên trái của widget. Điều này có thể vượt ra ngoài giới hạn để bạnclamp
nó giữa 0 và chiều rộng của widget._currentThumbValue
cần phải là giá trị từ0
đến1
, vì vậy bạn chia vị trí touch trên trục x cho tổng chiều rộng.- Gọi
markNeedsPaint
để repaint vị trí mới của thumb.
Nếu vị trí thumb thay đổi, ta sẽ biết rõ điều này thông qua giá trị state mới của widget. Gọi markNeedsPaint
để Flutter cập nhật state mới này và gọi markNeedsSemanticsUpdate
để Flutter cập nhật lại kết quả của state này.
Chạy lại ứng dụng và thử kéo thumb:
Tuyệt quá! Nó hoạt động!
Liệu bạn có cần một repaint boundary?
Để đạt được hiệu ứng hình ảnh như trên, bạn phải repaint widget mỗi khi có thay đổi sau sự kiện touch.
Bạn có thể quan sát những phần nào của ứng dụng đang được repaint bằng cách bật debug repaint rainbow. Hãy làm điều đó ngay bây giờ. Trong main.dart, hãy thay thế dòng này:
void main() => runApp(MyApp());
với những dòng sau đây:
void main() {
debugRepaintRainbowEnabled = true;
runApp(MyApp());
}
Flag debugRepaintRainbowEnabled
bật cầu vồng repaint lên màn hình. Một cách khác để làm điều đó là sử dụng Dart DevTools.
Bây giờ khởi động lại ứng dụng và di chuyển thumb.
Các vùng đang được repaint có một đường viền cầu vồng thay đổi màu sắc trên mỗi lần repaint. Như bạn có thể thấy, toàn bộ màn hình sẽ bị repaint mỗi khi bạn cập nhật vị trí thumb.
Audio progress bar sẽ phải repaint rất nhiều, không chỉ khi người dùng di chuyển thumb mà còn bất cứ khi nào nhạc phát. Vì lý do đó, sẽ tốt hơn nếu các widget của chúng ta repaint thôi và không ảnh hưởng các thằng khác.
Để hạn chế việc repaint widget của chúng ta, hãy thêm getter duy nhất này vào class RenderProgressBar
:
@override
bool get isRepaintBoundary => true;
Giá trị mặc định là false
, nhưng bây giờ bạn đang đặt nó thành true
. Chạy lại ứng dụng và thấy sự khác biệt:
Bây giờ chỉ có widget progress bar được repaint. Cây widget thì không.
Điều đó thật tuyệt phải không? Khi bạn đặt một repaint boundary xung quanh widget của mình, Flutter sẽ tạo một layer painting mới tách biệt với phần còn lại của cây. Làm như vậy sẽ tốn nhiều tài nguyên bộ nhớ hơn. Nếu widget repaint nhiều thì đó có thể là cách sử dụng tài nguyên tốt hơn.
Bạn có thể xóa flag cầu vồng repaint gỡ bug trong main.dart ngay bây giờ:
void main() {
// debugRepaintRainbowEnabled = true; // <-- delete this
runApp(MyApp());
}
Semantics
Semantics là việc thêm thông tin cần thiết để Flutter cho screen reader biết phải nói gì khi người dùng tương tác với widget của bạn.
Sử dụng semantics debugger
Trước hết, hãy xem những gì người dùng khiếm thị “nhìn thấy” khi họ sử dụng ứng dụng của bạn. Đi tới main.dart và bọc MaterialApp
bằng widget SemanticsDebugger
.
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SemanticsDebugger( // <-- Add this
child: MaterialApp(
home: Scaffold(...
Sau đó chạy lại ứng dụng của bạn:
Nó trống rỗng. Vì vậy, có vẻ như ứng dụng của chúng ta hoàn toàn vô dụng đối với những người khiếm thị. Thật không tốt. Hãy khắc phục điều đó.
Cấu hình cho semantics
Trong process_bar.dart, thêm các phương thức sau vào RenderProgressBar
:
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
// description
config.textDirection = TextDirection.ltr;
config.label = 'Progress bar';
config.value = '${(_currentThumbValue * 100).round()}%';
// increase action
config.onIncrease = increaseAction;
final increased = _currentThumbValue + _semanticActionUnit;
config.increasedValue = '${((increased).clamp(0.0, 1.0) * 100).round()}%';
// descrease action
config.onDecrease = decreaseAction;
final decreased = _currentThumbValue - _semanticActionUnit;
config.decreasedValue = '${((decreased).clamp(0.0, 1.0) * 100).round()}%';
}
static const double _semanticActionUnit = 0.05;
void increaseAction() {
final newValue = _currentThumbValue + _semanticActionUnit;
_currentThumbValue = (newValue).clamp(0.0, 1.0);
markNeedsPaint();
markNeedsSemanticsUpdate();
}
void decreaseAction() {
final newValue = _currentThumbValue - _semanticActionUnit;
_currentThumbValue = (newValue).clamp(0.0, 1.0);
markNeedsPaint();
markNeedsSemanticsUpdate();
}
Đây là những gì dòng code thực hiện:
describeSemanticsConfiguration
là nơi bạn set mô tả dạng text cho widget của mình bằng cách set các thuộc tính trên objectconfig
. Phương thức này sẽ được gọi bất cứ khi nào bạn gọimarkNeedsSemanticsUpdate
từ nơi khác trong render object.label
vàvalue
là những gì screen reader sẽ đọc khi mô tả widget này. Vì chúng ta không thực sự muốn screen reader nói “0,3478595746 Progress bar”, chúng ta đã chuyển đổi giá trị thumb thành một số đẹp hơn như 35%. Đây cũng là những gì widgetSlider
làm.- Người dùng screen reader có thể sử dụng các cử chỉ tùy chỉnh để thực hiện các task. Bằng cách thêm lệnh callback cho
onIncrease
vàonDecrease
, bạn đang hỗ trợ các cử chỉ tùy chỉnh đó. Điều này cung cấp một cách khác để di chuyển thumb vì những người dùng này không thể nhìn thấy vị trí trực quan của nó._SemanticActionUnit
của0,05
chỉ có nghĩa là bất cứ khi nào hành độngonIncrease
hoặconDecrease
được kích hoạt, thumb sẽ tăng hoặc giảm 5%.
Người dùng screen reader có thể sử dụng các cử chỉ tùy chỉnh để thực hiện các task. Bằng cách thêm lệnh callback cho onIncrease
và onDecrease
, bạn đang hỗ trợ các cử chỉ tùy chỉnh đó. Điều này cung cấp một cách khác để di chuyển thumb vì những người dùng này không thể nhìn thấy vị trí trực quan của nó. _SemanticActionUnit
của0,05
chỉ có nghĩa là bất cứ khi nào hành động onIncrease
hoặc onDecrease
được kích hoạt, thumb sẽ tăng hoặc giảm 5%.
Chạy lại ứng dụng của bạn:
Thật khó nhìn vì phông chữ nhỏ, nhưng bức ảnh cho biết “Progress bar (có thể điều chỉnh)”. Widget của bạn hiện “hiển thị” và có thể tương tác với screen reader. Điều đó rất tốt.
Xóa widget SemanticsDebugger
mà bạn đã thêm trước đó khỏi layout widget của mình trong main.dart. Chạy lại ứng dụng và mọi thứ sẽ trở lại bình thường.
Một vài lưu ý khác về semantics
Tôi vẫn chưa có nhiều kinh nghiệm sử dụng screen reader nên tôi chưa thực sự biết cách kiểm tra semantics trên thiết bị thực tế. Điều này có nghĩa là nhiều khả năng tôi vẫn còn thiếu xót một cái gì đó. Vì thế hãy nói cho tôi biết nếu bạn tìm thấy bug và tôi sẽ cập nhật bài viết.
Full code
main.dart
import 'package:flutter/material.dart';
import 'progress_bar.dart';
void main() {
// debugRepaintRainbowEnabled = true;
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: Container(
// color: Colors.cyan,
child: ProgressBar(
barColor: Colors.blue,
thumbColor: Colors.red,
thumbSize: 20.0,
),
),
),
),
);
}
}
progress_bar.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
class ProgressBar extends LeafRenderObjectWidget {
const ProgressBar({
Key? key,
required this.barColor,
required this.thumbColor,
this.thumbSize = 20.0,
}) : super(key: key);
final Color barColor;
final Color thumbColor;
final double thumbSize;
@override
RenderProgressBar createRenderObject(BuildContext context) {
return RenderProgressBar(
barColor: barColor,
thumbColor: thumbColor,
thumbSize: thumbSize,
);
}
@override
void updateRenderObject(
BuildContext context, RenderProgressBar renderObject) {
renderObject
..barColor = barColor
..thumbColor = thumbColor
..thumbSize = thumbSize;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(ColorProperty('barColor', barColor));
properties.add(ColorProperty('thumbColor', thumbColor));
properties.add(DoubleProperty('thumbSize', thumbSize));
}
}
class RenderProgressBar extends RenderBox {
RenderProgressBar({
required Color barColor,
required Color thumbColor,
required double thumbSize,
}) : _barColor = barColor,
_thumbColor = thumbColor,
_thumbSize = thumbSize {
// initialize the gesture recognizer
_drag = HorizontalDragGestureRecognizer()
..onStart = (DragStartDetails details) {
_updateThumbPosition(details.localPosition);
}
..onUpdate = (DragUpdateDetails details) {
_updateThumbPosition(details.localPosition);
};
}
void _updateThumbPosition(Offset localPosition) {
var dx = localPosition.dx.clamp(0, size.width);
_currentThumbValue = dx / size.width;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
Color get barColor => _barColor;
Color _barColor;
set barColor(Color value) {
if (_barColor == value) return;
_barColor = value;
markNeedsPaint();
}
Color get thumbColor => _thumbColor;
Color _thumbColor;
set thumbColor(Color value) {
if (_thumbColor == value) return;
_thumbColor = value;
markNeedsPaint();
}
double get thumbSize => _thumbSize;
double _thumbSize;
set thumbSize(double value) {
if (_thumbSize == value) return;
_thumbSize = value;
markNeedsLayout();
}
static const _minDesiredWidth = 100.0;
@override
double computeMinIntrinsicWidth(double height) => _minDesiredWidth;
@override
double computeMaxIntrinsicWidth(double height) => _minDesiredWidth;
@override
double computeMinIntrinsicHeight(double width) => thumbSize;
@override
double computeMaxIntrinsicHeight(double width) => thumbSize;
late HorizontalDragGestureRecognizer _drag;
@override
bool hitTestSelf(Offset position) => true;
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent) {
_drag.addPointer(event);
}
}
@override
void performLayout() {
size = computeDryLayout(constraints);
}
@override
Size computeDryLayout(BoxConstraints constraints) {
final desiredWidth = constraints.maxWidth;
final desiredHeight = thumbSize;
final desiredSize = Size(desiredWidth, desiredHeight);
return constraints.constrain(desiredSize);
}
double _currentThumbValue = 0.5;
@override
void paint(PaintingContext context, Offset offset) {
final canvas = context.canvas;
canvas.save();
canvas.translate(offset.dx, offset.dy);
// paint bar
final barPaint = Paint()
..color = barColor
..strokeWidth = 5;
final point1 = Offset(0, size.height / 2);
final point2 = Offset(size.width, size.height / 2);
canvas.drawLine(point1, point2, barPaint);
// paint thumb
final thumbPaint = Paint()..color = thumbColor;
final thumbDx = _currentThumbValue * size.width;
final center = Offset(thumbDx, size.height / 2);
canvas.drawCircle(center, thumbSize / 2, thumbPaint);
canvas.restore();
}
@override
bool get isRepaintBoundary => true;
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
// description
config.textDirection = TextDirection.ltr;
config.label = 'Progress bar';
config.value = '${(_currentThumbValue * 100).round()}%';
// increase action
config.onIncrease = increaseAction;
final increased = _currentThumbValue + _semanticActionUnit;
config.increasedValue = '${((increased).clamp(0.0, 1.0) * 100).round()}%';
// descrease action
config.onDecrease = decreaseAction;
final decreased = _currentThumbValue - _semanticActionUnit;
config.decreasedValue = '${((decreased).clamp(0.0, 1.0) * 100).round()}%';
}
static const double _semanticActionUnit = 0.05;
void increaseAction() {
final newValue = _currentThumbValue + _semanticActionUnit;
_currentThumbValue = (newValue).clamp(0.0, 1.0);
markNeedsPaint();
markNeedsSemanticsUpdate();
}
void decreaseAction() {
final newValue = _currentThumbValue - _semanticActionUnit;
_currentThumbValue = (newValue).clamp(0.0, 1.0);
markNeedsPaint();
markNeedsSemanticsUpdate();
}
}
Cập nhật
Bạn vẫn ở đây? Thật tuyệt vời, vậy bạn có muốn nghe về những tiến bộ mà tôi đã đạt được sau khi kết thúc bài viết này không?
Sau khi thực hiện các cải tiến bổ sung cho widget, cuối cùng tôi đã có thể xuất bản nó trên Pub dưới dạng audio_video_progress_bar
. Nó trông như thế này:
Đây là một số cải tiến:
- Thêm thời lượng vào
buffered
để hiển thị tiến trình của việc tải xuống bộ đệm cho nội dung được phát trực tuyến. - Thêm text label cho tiến trình playing hiện tại và tổng thời gian.
Bài viết được dịch từ đây.
Bài viết liên quan
Tự học Dart: Các Dart Operators (toán tử) bạn cần biết
Sep 02, 2023 • 18 min read
Flutter cơ bản: Điều cần biết khi lập trình ứng dụng đầu tiên
Aug 23, 2023 • 13 min read
Dart là gì? Giới thiệu cơ bản về ngôn ngữ lập trình Dart
Aug 21, 2023 • 11 min read
Flutter là gì? Vì sao nên học công cụ lập trình Flutter?
Aug 19, 2023 • 11 min read
Flutter cơ bản: Widget Tree, Element Tree & Render Tree
Aug 19, 2023 • 10 min read
So sánh StatelessWidget và StatefulWidget
Jul 17, 2022 • 12 min read