[Mobile – Flutter] Slider trong Flutter

by troioiconngu
703 views

Bản thân Flutter là một framework khá hoàn thiện đến mức việc tạo giao diện với Flutter trở nên dễ dàng và nhanh chóng đến mức bất ngờ! Điều này khiến lập trình viên Flutter cảm thấy việc lập trình mobile quá dễ (trừ khi họ là người đã từng nếm trái đắng từ khi code native). Mình cũng thấy dễ cho đến khi nhìn thấy giao diện ứa nước mắt mà designer gửi (đôi khi là kèm theo video animation siêu xịn…).

Thế bạn đã trang bị cho mình gì để ứng phó trước những design đẹp (hoặc k) nào? Bạn đã biết custom Slider trong Flutter chưa? Hay chỉ là những bé slider cũ mèn như này này này:

mặc định
range slider
cupertino *hình ảnh chụp từ video youtube nên hơi mờ

Nhưng khi nhận được design thì trông như này

như này nè
rồi như này
trông cũng dễ?

Chắc ai nhận design cũng sẽ nghĩ: trông dễ phết, khó thì dùng thư viện (nhiều thư viện Slider quá mà). Có thể kể ra một số thư viện cho ai muốn dùng nè:

Đọc đến đây thì ai thích dùng thư viện có thể dừng lại và tham khảo 3 thư viện này. Đôi khi một số lại thấy thư viện thì quá là nặng thêm nhiều thứ lắt nhắt, rồi cho customize đủ thứ mà cái mình cần thì không có? Hoặc bạn muốn tự mình thử sức làm những slider này, thì tiếp tục đọc cách custom slider nào :3

Đầu tiên, chúng ta sẽ tìm hiểu về các thành phần của 1 slider và tên gọi của nó trong Flutter với thiết kế theo Material: (đoạn này cop trên document lười dịch nên nhờ bạn đọc tạm nhé)

  • The “thumb”, which is a shape that slides horizontally when the user drags it.
  • The “track”, which is the line that the slider thumb slides along.
  • The “value indicator”, which is a shape that pops up when the user is dragging the thumb to indicate the value being selected.
  • The “active” side of the slider is the side between the thumb and the minimum value.
  • The “inactive” side of the slider is the side between the thumb and the maximum value.
  • The “tick marks”, which are regularly spaced marks that are drawn when using discrete divisions.
  • The “value indicator”, which appears when the user is dragging the thumb to indicate the value being selected.
  • The “overlay”, which appears around the thumb, and is shown when the thumb is pressed, focused, or hovered. It is painted underneath the thumb, so it must extend beyond the bounds of the thumb itself to actually be visible.

Để thiết kế ra một Slider thì đầu tiên chúng ta sẽ xem đến các widget trực tiếp để tạo ra slider: Slider, RangeSlider, and CupertinoSlider(Flutter Widget of the Week) – YouTube. Tuy nhiên khi xem document (ờ lại là document, document của Flutter là tài liệu hữu ích nhất mà dev Flutter có thể tìm được bên cạnh source code open của nó) thì bạn phát hiện ra nó không có nhiều thứ liên quan đến giao diện cho lắm, ngoài việc cho phép đổi tí màu. Bạn bắt đầu tuyệt vọng.

một constructor của slider
có gì đó

và rồi cuối document, họ bảo, vẻ bề ngoài của nó thì dùng SliderThemeData từ widget SliderTheme hoặc một cái theme nào đó cha ông của nó mà có định nghĩa sliderTheme. Yeah XD

Bạn tiếp tục đi đến wiki của SliderThemeData xem tùy chỉnh được gì, ở đây nhé SliderThemeData class – material library – Dart API (flutter.dev). Có thể chỉnh được kha khá, hình tròn hình chữ nhật, bo góc (hoặc k), … bạn có thể dừng lại, đọc, và thử xem bạn có thể custom như nào (ảnh phía trên là 1 slider đã được 1 tác giả khác tạo ra nhờ tùy chỉnh các thuộc tính này).

Để rồi nhận ra rằng: nó không giống mấy cái design nhận được xíu nào 🙁

hình ảnh slider trong blog cuối bài

Thế làm sao để custom chúng nó như này? Hãy xem Flutter Team đã tạo ra Slider như nào đã nhé :3

ồ là một file hơn 3000 dòng code và rất nhiều tác giả

Họ đã tạo ra RoundedRectSliderTrackShape, RoundSliderThumbShape, PaddleSliderValueIndicatorShape, PaddleSliderValueIndicatorShape, RoundSliderTickMarkShape,… và document cũng nói là:

The thumb, track, tick marks, value indicator, and overlay can be customized by creating subclasses of SliderTrackShapeSliderComponentShape, and/or SliderTickMarkShape. See RoundSliderThumbShapeRectangularSliderTrackShapeRoundSliderTickMarkShapeRectangularSliderValueIndicatorShape, and RoundSliderOverlayShape for examples.

Document

Nên cứ nghe theo thôi ha, họ bảo code mẫu rồi đấy, muốn custom thì xem mà học :3 họ bảo kế thừa mấy cái có sẵn này đi mà custom, nhưng custom sao thì họ không nói 🙁 nên t ở đây để giúp bạn đây hehe. Không dài dòng nữa, bắt đầu thôi :3

Khi bạn extend các lớp kể ở trên, bạn sẽ được trình gợi ý yêu cầu override hàm paint, đây là hàm để vẽ nên chúng nó. Trong hàm paint có một số thứ bạn cần chú ý:
– Đầu tiên là PaintingContext context, ở đây bạn sẽ lấy được canvas ra và vẽ những gì bạn cần (context.canvas)
– Tiếp theo là center với Thumb và parentBox với Track, bạn sẽ tìm được những điểm hữu dụng để vẽ
– Tiếp theo là sliderTheme, từ đó bạn có thể lấy ra các thuộc tính khác mà bạn có thể truyền vào theme của slider (màu active, inactive…)
– Cuối cùng, mn luôn luôn có thể tham khảo code open của Flutter xem họ làm như nào với các shape mặc định, hoặc tham khảo code của mình sau đây :3

Đây là code để tạo ra chiếc slider này

Chụp từ màn hình điện thoại
import 'package:flutter/material.dart';

class CustomSlider extends StatelessWidget {
  final double max, min, value;
  final void Function(double) onValueChange;

  const CustomSlider({
    required this.min,
    required this.max,
    required this.value,
    required this.onValueChange,
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        border: Border.all(color: Colors.grey),
        borderRadius: BorderRadius.circular(10),
      ),
      padding: const EdgeInsets.fromLTRB(4, 16, 4, 16),
      child: Row(
        children: [
          Text(min.toInt().toString()),
          const SizedBox(width: 6),
          Expanded(
            child: SliderTheme(
              data: SliderTheme.of(context).copyWith(
                trackHeight: 14.0,
                trackShape: CustomTrackShape(),
                activeTrackColor: const Color(0xFF219653),
                inactiveTrackColor: const Color(0xFFF1E1E1),
                thumbShape: CustomSliderMarkShape(sliderValue: value, tickMarkRadius: 16),
                thumbColor: const Color(0xFF219653),
                // overlayColor: Color(0xFF219653).withOpacity(0.5),
                // overlayShape: RoundSliderOverlayShape(overlayRadius: 24),
                overlayShape: SliderComponentShape.noOverlay,
                showValueIndicator: ShowValueIndicator.always,
                valueIndicatorShape: const RectangularSliderValueIndicatorShape(),
                valueIndicatorColor: Colors.black,
                valueIndicatorTextStyle: const TextStyle(
                  color: Colors.white,
                  fontSize: 16.0,
                ),
              ),
              child: Slider(
                min: min,
                max: max,
                value: value,
                label: '${value.round()}',
                onChanged: onValueChange,
              ),
            ),
          ),
          const SizedBox(width: 6),
          Text(max.toInt().toString()),
        ],
      ),
    );
  }
}

class CustomSliderMarkShape extends SliderComponentShape {
  final double tickMarkRadius;
  final double sliderValue;

  CustomSliderMarkShape({
    required this.tickMarkRadius,
    required this.sliderValue,
  });

  @override
  Size getPreferredSize(bool isEnabled, bool isDiscrete) {
    return Size(tickMarkRadius, tickMarkRadius);
  }

  @override
  void paint(
    PaintingContext context,
    Offset center, {
    required Animation<double> activationAnimation,
    required Animation<double> enableAnimation,
    required bool isDiscrete,
    required TextPainter labelPainter,
    required RenderBox parentBox,
    required SliderThemeData sliderTheme,
    required TextDirection textDirection,
    required double value,
    required double textScaleFactor,
    required Size sizeWithOverflow,
  }) {
    final Canvas canvas = context.canvas;

    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromCenter(center: center, width: 36, height: 24), const Radius.circular(16)),
      Paint()..color = Colors.white,
    );

    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromCenter(center: center, width: 36, height: 24), const Radius.circular(16)),
      Paint()..color = Colors.black
        ..style = PaintingStyle.stroke
        ..strokeWidth = 2,
    );

    TextSpan span = TextSpan(
      style: TextStyle(
        fontSize: tickMarkRadius * 0.9,
        fontWeight: FontWeight.w700,
        color: Colors.black
      ),
      text: sliderValue.round().toString(),
    );

    TextPainter tp = TextPainter(
      text: span,
      textAlign: TextAlign.center,
      textDirection: TextDirection.ltr,
    );

    tp.layout();

    Offset textCenter = Offset(
      center.dx - (tp.width / 2),
      center.dy - (tp.height / 2),
    );
    tp.paint(canvas, textCenter);
  }
}

class CustomTrackShape extends RoundedRectSliderTrackShape {
  @override
  Rect getPreferredRect({
    required RenderBox parentBox,
    Offset offset = Offset.zero,
    required SliderThemeData sliderTheme,
    bool isEnabled = false,
    bool isDiscrete = false,
  }) {
    final double trackHeight = sliderTheme.trackHeight ?? 8;
    final double trackLeft = offset.dx;
    final double trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2;
    final double trackWidth = parentBox.size.width;
    return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight);
  }
}
// sử dụng
CustomSlider(
  max: 100,
  min: 10,
  onValueChange: (_) {  },
  value: 30,
),


Mình đã custom cái slider này từ rất lâu rồi (nên code của nó mình không chắc là ổn lắm).

Và lí do mình viết bài này chủ yếu để nhớ kĩ hơn và muốn chia sẻ với mọi người khi mà chiều nay mình đã custom ra mấy cái extend SliderComponentShape, rồi ghép vào SliderTheme mà reload restart hoài UI không nhận :'( Mình rất bối rối muốn ném máy ra cửa sổ thì nhận ra nếu là RangeSlider thì cần extend RangeSliderTrackShape, RangeSliderThumbShape… (thêm Range cơ). Hóa ra do mình không đọc kĩ (tiện muốn kể chuyện InteractiveViewer ghê mà ai muốn nghe ib nhé :v)

range slider sau khi custom
import 'dart:math';

import 'package:flutter/material.dart';
import 'dart:ui' as ui;

class CustomRangeSliderTrack extends RangeSliderTrackShape {
  const CustomRangeSliderTrack();

  @override
  Rect getPreferredRect({
    required RenderBox parentBox,
    Offset offset = Offset.zero,
    required SliderThemeData sliderTheme,
    bool isEnabled = false,
    bool isDiscrete = false,
  }) {
    final double overlayWidth = sliderTheme.overlayShape!.getPreferredSize(isEnabled, isDiscrete).width;
    final double trackHeight = sliderTheme.trackHeight!;

    final double trackLeft = offset.dx + overlayWidth / 2;
    final double trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2;
    final double trackRight = trackLeft + parentBox.size.width - overlayWidth;
    final double trackBottom = trackTop + trackHeight;
    // If the parentBox'size less than slider's size the trackRight will be less than trackLeft, so switch them.
    return Rect.fromLTRB(min(trackLeft, trackRight), trackTop, max(trackLeft, trackRight), trackBottom);
  }

  @override
  void paint(
    PaintingContext context,
    Offset offset, {
    required RenderBox parentBox,
    required SliderThemeData sliderTheme,
    required Animation<double> enableAnimation,
    required Offset startThumbCenter,
    required Offset endThumbCenter,
    bool isEnabled = false,
    bool isDiscrete = false,
    required TextDirection textDirection,
    double additionalActiveTrackHeight = 2,
  }) {
    final ColorTween inactiveTrackColorTween = ColorTween(
      begin: sliderTheme.disabledInactiveTrackColor,
      end: sliderTheme.inactiveTrackColor,
    );

    final Rect trackRect = getPreferredRect(
      parentBox: parentBox,
      offset: offset,
      sliderTheme: sliderTheme,
      isEnabled: isEnabled,
      isDiscrete: isDiscrete,
    );

    final Paint activePaint = Paint()
      ..shader = ui.Gradient.linear(
        Offset(0, trackRect.top),
        Offset(0, trackRect.bottom),
        [
          Color(0xFF4BC65E),
          Color(0xFF379A46),
        ],
      );
    final Paint inactivePaint = Paint()
      ..color = inactiveTrackColorTween.evaluate(enableAnimation)!;
    final Size thumbSize = sliderTheme.rangeThumbShape!.getPreferredSize(isEnabled, isDiscrete);
    final double thumbRadius = thumbSize.width / 2;
    assert(thumbRadius > 0);

    final Radius trackRadius = Radius.circular(trackRect.height / 2);

    context.canvas.drawRRect(
      RRect.fromLTRBAndCorners(
        trackRect.left,
        trackRect.top,
        startThumbCenter.dx,
        trackRect.bottom,
        topLeft: trackRadius,
        bottomLeft: trackRadius,
      ),
      inactivePaint,
    );
    context.canvas.drawRect(
      Rect.fromLTRB(
        startThumbCenter.dx,
        trackRect.top - (additionalActiveTrackHeight / 2),
        endThumbCenter.dx,
        trackRect.bottom + (additionalActiveTrackHeight / 2),
      ),
      activePaint,
    );
    context.canvas.drawRRect(
      RRect.fromLTRBAndCorners(
        endThumbCenter.dx,
        trackRect.top,
        trackRect.right,
        trackRect.bottom,
        topRight: trackRadius,
        bottomRight: trackRadius,
      ),
      inactivePaint,
    );
  }
}

class CustomRangeSliderThumb extends RangeSliderThumbShape {
  const CustomRangeSliderThumb();
  
  @override
  Size getPreferredSize(bool isEnabled, bool isDiscrete) {
    return Size.fromRadius(20);
  }
  
  @override
  void paint(PaintingContext context,
    Offset center, {
    required Animation<double> activationAnimation, 
    required Animation<double> enableAnimation, 
    bool isDiscrete = false,
    bool isEnabled = false,
    bool? isOnTop,
    required SliderThemeData sliderTheme,
    TextDirection? textDirection,
    Thumb? thumb,
    bool? isPressed,
  }) {
    final Canvas canvas = context.canvas;
    final outerPaint = Paint()
      ..style = PaintingStyle.fill
      ..shader = ui.Gradient.linear(
        Offset(0, 0),
        Offset(0, 30),
        [
          Color(0xFF4BC65E),
          Color(0xFF379A46),
        ],
      );
    canvas.drawCircle(center, 10, outerPaint);
    canvas.drawCircle(center, 5, Paint()..color = Colors.white);
  }
}
// dùng như này để có 1 range slider như ý
// có thể custom thêm gì đó tùy ý nha
SliderTheme(
  data: SliderThemeData(
    trackHeight: 4,
    rangeTrackShape: CustomRangeSliderTrack(),
    rangeThumbShape: CustomRangeSliderThumb(),
  ),
  child: RangeSlider(
    values: priceRange,
    min: minPrice,
    max: maxPrice,
    onChanged: (value) {
      setState(() {
        priceRange = value;
      });
    },
  ),
),

Tham khảo: Flutter Slider widgets: A deep dive with examples – LogRocket Blog

Leave a Comment

* By using this form you agree with the storage and handling of your data by this website.

You may also like