Facebook Pixel

Hướng dẫn custom RenderObject của riêng bạn

11 Feb, 2022

Nguyên

Author

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

Hướng dẫn custom RenderObject của riêng bạn

Mục Lục

Render object widget tùy chỉnh mà bạn sẽ tạo ra

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.0Dart 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ó:

Dart
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ụng SingleChildRenderObjectWidgetvà đối với nhiều children, bạn sẽ sử dụng MultiChildRenderObjectWidget.
  • 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.

Dart
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, thumbColorthumbSize đượ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.

Dart
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:

Dart
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:

Dart
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:

Dart
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:

Dart
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 markNeedsPaint, bây giờ bạn đang gọi markNeedsLayout. Đó 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:

Dart
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:

Dart
@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ểu BoxContraints là một thuộc tính của RenderBox. Các BoxContraints 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ọn maxWidth, 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ính thumbSize.
  • 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ếu thumbSize 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ủa RenderBox. Bạn chỉ nên đặt nó bên trong phương thức performLayout. Ở mọi nơi khác, bạn nên gọi markNeedsLayout. Ngoài ra, phương thức computeDryLayout 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:

Dart
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ới 100.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.
Text height being affected by width constraint

Để 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:

Dart
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:


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:

Dart
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 đến 1 đạ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ụng 0,5 để đặt nó ở giữa thanh.
  • Tham số offset của phương thức paint 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:

Dart
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:

Dart
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';

Và sau đó thêm code bên dưới vào RenderProgressBar:

Dart
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 trong hitTestSelf để 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ên position 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êm PointerDownEvent 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:

Dart
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:

Dart
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ạn clamp nó giữa 0 và chiều rộng của widget.
  • _currentThumbValue cần phải là giá trị từ 0 đến 1, 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:

Dart
void main() => runApp(MyApp());

với những dòng sau đây:

Dart
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:

Dart
@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ờ:

Dart
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.

Dart
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:

SemanticsDebugger view

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:

Dart
@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 object config. Phương thức này sẽ được gọi bất cứ khi nào bạn gọi markNeedsSemanticsUpdate từ nơi khác trong render object.
  • labelvalue 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ì widget Slider 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 onIncreaseonDecrease, 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%.


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 onIncreaseonDecrease, 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:

SemanticsDebugger view “Progress bar (adjustable)”

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

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

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

Lập trình backend expressjs

xây dựng hệ thống microservices
  • Kiến trúc Hexagonal và ứng dụngal font-
  • TypeScript: OOP và nguyên lý SOLIDal font-
  • Event-Driven Architecture, Queue & PubSubal font-
  • Basic scalable System Designal font-

Đăng ký nhận thông báo

Đừng bỏ lỡ những bài viết thú vị từ 200Lab