Flutter How to show selected area on Slider

eye-catch Dart and Flutter

Some audio players have a feature to repeat a specific area. The function seems to be called AB repeat or A-B repeat function.

slider is used to show where the current position is and how long the audio is. I wanted to specify the area to repeat the selected area.

Flutter offers RangeSlider that has two cursors (is it called a cursor?) but AB repeat feature needs 3 to show the following things.

  • Audio Current position
  • Start position of AB repeat
  • End position of AB repeat

I will share the way since I implemented it in my flutter sample project. If you try to play with it, go to my Flutter sample project.

Sponsored links

Place a widget on the same area to show the selected area

Firstly, we somehow need to show the selected area on a slider widget but Slider widget doesn’t have such a feature. So, we need to put another widget there. To put multiple widgets in the same area, we can use Stack widget. Let’s check the code.

final stack = Stack(
  children: [
    Positioned(
      top: 10,
      left: leftGlobalX,
      child: ColoredBox(
        color: Theme.of(context).primaryColor.withOpacity(0.5),
        child: SizedBox(
          height: 30,
          width: selectedAreaWidth ?? 10,
        ),
      ),
    ),
    slider,
  ],
);

In stack, Positioned widget is used to determine where to put a widget. We can control the position precisely with it. The position value is the relative position of the parent object. We need to determine only the left position to show the start position of AB repeat area. top is also defined here just because of adjusting the appearance.

It has a ColoredBox to show the selected area. It is placed in a specific area. Then, a slider widget is on it. It is important to put the slider widget after the ColoredBox widget. If the order is opposite, we can’t grab/move the slider position on the selected area.

Sponsored links

Necessary items to show the area

What items do we need to show the area? It’s basically only the following two.

  • Start position of the area
  • End position of the area

Then, width of the area is end position – start position. However, the value of the slider is double and relative value. It can be set to value property.

double sliderPosition = 0;

@override
Widget build(BuildContext context) {
  ...
  final slider = Slider(
    onChanged: (double v) {
      setState(() {
        sliderPosition = v;
      });
    },
    value: sliderPosition,
  );
  ...

The range of the value is 0 – 1. So, it’s not possible to get the start/end global position without calculation.

Considering this, what we need are…

  • Start relative position of the area on the slider
  • End relative position of the area on the slider
  • Start global position of the area on the slider
  • End global position of the area on the slider
  • Width of the slider

The width of the area can be calculated only with start/end relative positions and the width of the slider. However, we need to set value to Positioned widget to put the start position.

In this example, the width of the slider is not fixed. It changes depending on the size of the window. We need to know how to get the position and the width of the slider. Check the following post if you want to know how to do it in detail.

technicalfeeder.com/2023/02/flutter-how-to-get-widget-height-width-x-and-y-position/

Set start position and the area width

Let’s consider how many cases we need to handle.

  • No End position
    • Set start position
    • Unset start position if the button is pressed twice on the same position
  • End position exists
    • Start position is bigger than end position
      • Unset start position
    • Unset start position if the button is pressed twice on the same position
    • Start position is smaller than end position
      • Set start position

We need to handle all cases above. Let’s see the basic implementation of the TextButton for A. If we implement the cases above, the code will look like the following.

TextButton(
  child: generateBorderedText("A"),
  onPressed: () => setState(() {
    if (endRelativePosition == null) {
      if (startRelativePosition == sliderPosition) {
        setState(() {});
      } else {
        setState(() {});
      }
      return;
    }

    if (endRelativePosition! <= sliderPosition) {
      if (startRelativePosition != null) {
        setState(() {});
      }
      return;
    }

    if (startRelativePosition == sliderPosition) {
      setState(() {});
      return;
    }

    if (endRelativePosition! > sliderPosition) {
      setState(() {});
    }
  }),
),

! is not needed if you use else or make the order opposite which means that non-null case is written first. It’s up to you.

The following 3 variables need to be updated in all places.

  • startRelativePosition
  • leftGlobalX
  • selectedAreaWidth

When end position is not set

Let’s check the case when end position is not set.

TextButton(
  child: generateBorderedText("A"),
  onPressed: () => setState(() {
    if (endRelativePosition == null) {
      if (startRelativePosition == sliderPosition) {
        setState(() {
          startRelativePosition = null;
          leftGlobalX = null;
          selectedAreaWidth = null;
        });
      } else {
        setState(() {
          startRelativePosition = sliderPosition;
          leftGlobalX = sliderWidth * sliderPosition;
          selectedAreaWidth = sliderWidth * (1 - sliderPosition);
        });
      }
      return;
    }

All variables need to be set null to unset. It’s clear.

startRelativePosition is the current position of the slider position. We can use sliderPosition value because it’s set to value property of slider widget.

Since the slider starts from the left edge of the window, we can calculate leftGlobalX by sliderWidth * sliderPosition Slider position is relative. So this formula results for example 500 * 0.2 = 100. If the slider widget is used in another widget, add the dx position of the slider widget.

The slider width is dynamic but it can be read in the following way.

double get sliderWidth {
  final renderBox = keyForSlider.currentContext?.findRenderObject() as RenderBox;
  return renderBox.size.width;
}

Check the following post if you need to know the detail.

When the start position is set, ColoredBox needs to be filled between the start position and the end of the slider. That’s why calculating (1 - sliderPosition).

The result is the following.

When end position is set

When start position is bigger than end position

In this case, unset the start position but we need to be cafule with the width. When it is unset, the width needs to be changed because the area needs to be between slider start position and the end position.

TextButton(
  child: generateBorderedText("A"),
  onPressed: () => setState(() {
    ...

    if (endRelativePosition! <= sliderPosition) {
      if (startRelativePosition != null) {
        setState(() {
          startRelativePosition = null;
          leftGlobalX = null;
          selectedAreaWidth = rightGlobalX!;
        });
      }
      return;
    }

    ...
  }),
),

The width of the area is the same as end global position.

When the button is pressed twice on the same position

We want to unset the start position but the width of the area needs to be adjusted.

TextButton(
  child: generateBorderedText("A"),
  onPressed: () => setState(() {
    ...

    if (startRelativePosition == sliderPosition) {
      setState(() {
        startRelativePosition = null;
        leftGlobalX = null;
        selectedAreaWidth = rightGlobalX;
      });
      return;
    }

    ...
  }),
),

This is the same as the previous one, so let’s combine it.

TextButton(
  child: generateBorderedText("A"),
  onPressed: () => setState(() {
    ...

    if (startRelativePosition == sliderPosition || startRelativePosition == sliderPosition) {
      setState(() {
        startRelativePosition = null;
        leftGlobalX = null;
        selectedAreaWidth = rightGlobalX;
      });
      return;
    }

    ...
  }),
),

When start position is smaller than end position

Finally, we can calculate the selected width in the formula that I explained first.

TextButton(
  child: generateBorderedText("A"),
  onPressed: () => setState(() {
    ...

    if (endRelativePosition! > sliderPosition) {
      setState(() {
        startRelativePosition = sliderPosition;
        leftGlobalX = sliderWidth * sliderPosition;
        selectedAreaWidth = rightGlobalX! - leftGlobalX!;
      });
    }
  }),
),

Sample video

I didn’t explain how to set/unset end position but it is basically the same as start position. So, let’s check the final behavior here.

Adjust slider padding space

I found this bug after writing the article. Look at the following images. The selected area is not on the slider when the slider position is near to start or end.

This is because the slider widget has padding. I checked the slider code in GitHub.

const double _kPadding = 8.0; // <---- padding
const double _kSliderHeight = 2.0 * (CupertinoThumbPainter.radius + _kPadding);
const double _kSliderWidth = 176.0;
const Duration _kDiscreteTransitionDuration = Duration(milliseconds: 500);
...


  double get _trackLeft => _kPadding; // <---- padding
  double get _trackRight => size.width - _kPadding; // <---- padding
  double get _thumbCenter {
    final double visualPosition;
    switch (textDirection) {
      case TextDirection.rtl:
        visualPosition = 1.0 - _value;
        break;
      case TextDirection.ltr:
        visualPosition = _value;
        break;
    }
    // padding CupertinoThumbPainter.radius is 14
    return lerpDouble(_trackLeft + CupertinoThumbPainter.radius, _trackRight - CupertinoThumbPainter.radius, visualPosition)!;
  }

@override
void paint(PaintingContext context, Offset offset) {
...
  // drawing here
  if (visualPosition > 0.0) {
    final Paint paint = Paint()..color = rightColor;
    canvas.drawRRect(RRect.fromLTRBXY(trackLeft, trackTop, trackActive, trackBottom, 1.0, 1.0), paint);
  }
...

Padding _kPadding = 8.0 and CupertinoThumbPainter.radius = 14 is used for both right and left. Therefore, we need to adjust it. Let’s adjust sliderWidth first.

  double get sliderWidth {
    final renderBox = keyForSlider.currentContext?.findRenderObject() as RenderBox;
    return renderBox.size.width - (14 + 8) * 2;
  }

I don’t want to add the adjustment everywhere, so I introduce getter and setter. Change the name of xxxxGlobalX.

double? _leftGlobalX;
double? _rightGlobalX;

Then, add getter and setter.

void set leftGlobalX(double? value) {
  if (value != null) {
    _leftGlobalX = value + 22;
  } else {
    _leftGlobalX = null;
  }
}

double? get leftGlobalX => _leftGlobalX;

void set rightGlobalX(double? value) {
  if (value != null) {
    _rightGlobalX = value + 22;
  } else {
    _rightGlobalX = null;
  }
}

double? get rightGlobalX => _rightGlobalX;

In this way, we don’t have to change another place.

The adjustment works very well. The selected area starts from the center of the slider.

Change Slider padding value

If the slider padding value needs to be changed, define SliderTheme. If padding is not necessary, set SliderComponentShape.noThumb or SliderComponentShape.noOverlay to overlayShape. If more control is needed, define custom TrackShape.

final sliderTheme = SliderTheme(
  data: SliderThemeData(
    // overlayShape: SliderComponentShape.noThumb,
    // overlayShape: SliderComponentShape.noOverlay,
    trackShape: CustomTrackShape(),
  ),
  child: slider,
);

This is the code. The value 100 is the padding value here. It’s multiplied for trackWidth because the padding needs to be applied both left and right side.

class CustomTrackShape extends RoundedRectSliderTrackShape {
  @override
  Rect getPreferredRect({
    required RenderBox parentBox,
    Offset offset = Offset.zero,
    required SliderThemeData sliderTheme,
    bool isEnabled = false,
    bool isDiscrete = false,
  }) {
    final trackHeight = sliderTheme.trackHeight;
    final trackLeft = offset.dx + 100;
    final trackTop = offset.dy + (parentBox.size.height - trackHeight!) / 2;
    final trackWidth = parentBox.size.width - 100 * 2;
    return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight);
  }
}

100 is big enough to show the apparent result.

Adjust the area when the window size changes

The current implementation doesn’t adjust the selected area when the window size changes. If this feature is used on a desktop, it must be adjusted correctly.

First of all, we have to get the window size change event. It is implemented in WidgetsBindingObserver. Let’s add it to the widget.

class _SpecifyAreaOnSliderState extends State<SpecifyAreaOnSlider> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

Then, we can handle the event.

@override
void didChangeMetrics() {
  if (_leftGlobalX != null && _rightGlobalX == null) {
    setState(() {
      leftGlobalX = startRelativePosition! * sliderWidth;
      selectedAreaWidth = sliderWidth * (1 - startRelativePosition!);
    });
  } else if (_leftGlobalX == null && _rightGlobalX != null) {
    setState(() {
      rightGlobalX = endRelativePosition! * sliderWidth;
      selectedAreaWidth = _rightGlobalX!;
    });
  } else if (_leftGlobalX != null && _rightGlobalX != null) {
    setState(() {
      leftGlobalX = startRelativePosition! * sliderWidth;
      rightGlobalX = endRelativePosition! * sliderWidth;
      selectedAreaWidth = _rightGlobalX! - leftGlobalX!;
    });
  }
}

setState should be used only for the data change. It should not include other calculations. Therefore, I wrote it in the way above.

We can see that the selected area follows the window size.

Comments

Copied title and URL