Skip to content

feat(sr): Material and Cupertino Slider recorder added#1035

Merged
JuanNaranjoDD merged 12 commits into
developfrom
juan.naranjo/widget_support
May 19, 2026
Merged

feat(sr): Material and Cupertino Slider recorder added#1035
JuanNaranjoDD merged 12 commits into
developfrom
juan.naranjo/widget_support

Conversation

@JuanNaranjoDD
Copy link
Copy Markdown
Contributor

What and why?

Adds Session Replay support for Flutter's Slider and CupertinoSlider widgets so they render in replays instead of falling through to the generic CustomPaint recorder. Previously these widgets either weren't represented or appeared as blank/incorrect shapes, hiding user interaction with sliders from the replay UI.

How?

Two new element recorders (SliderRecorder and CupertinoSliderRecorder) capture each slider as a small stack of SRShapeWireframes — inactive track, optional secondary, active, optional ticks/gap/stop indicator, and thumb on top — built from the widget's resolved geometry and colors. Colors mirror Flutter's own M2 / M3-2023 / M3-2024 defaults and honor a local SliderTheme override (via SliderTheme.of(element)), while the Cupertino side resolves through CupertinoDynamicColor. The M3-2024 gap finds its background color by walking the slider's ancestors (Material / ColoredBox / Scaffold / etc.), and masked sliders anchor the thumb at the track midpoint so the value isn't leaked. Both recorders share a ShapeWireframeBuilder.shape(...) helper to avoid duplicated wireframe construction.

Review checklist

  • This pull request has appropriate unit and / or integration tests
  • This pull request references a Github or JIRA issue — 10253

@JuanNaranjoDD JuanNaranjoDD requested a review from a team as a code owner May 18, 2026 15:01
@JuanNaranjoDD JuanNaranjoDD changed the title [Flutter] [Session Replay] Widget Support - Slider feat(sr): Material and Cupertino Slider recorder added May 18, 2026
Copy link
Copy Markdown
Member

@fuzzybinary fuzzybinary left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few minor suggestions, nothing too major.

Fix up dartfmt and I'll take a second look and approve.

Comment on lines +563 to +565
// With maskAllInputs every thumb should be anchored at the track midpoint
// regardless of the supplied value (0.0, 0.5, 1.0 should all look the same).
final fixture = MaterialApp(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sure you verified this, but I just want to be sure this is what iOS / Android do for masked sliders. They don't do anything else special to indicate they've been masked?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s actually my suggestion. In a native app, there’s no reason to mask a slider. Masking is a Session Replay feature, which means we’re intentionally not representing what is actually shown on the screen.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like iOS and Android show the track, but not the thumb. I'm okay having the thumb just be at the middle, but we should verify we're okay with that being different from the other platforms.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I noticed is that when the onChanged property is set to null, the thumb is still displayed, the only difference is the color used to represent the widget (which I already handled). Or do you mean that the other SDKs only display the track in that state?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is specifically when a user asks to mask inputs by specifying .maskAllInputs either at the root or with a masking override. It looks like in that case iOS and Android only display the track and not the thumb, but confirm with @gonzalezreal.


return SpecificElement(
subtreeStrategy: CaptureNodeSubtreeStrategy
.ignore, // Ignore subtree to prevent CustomPaintRecorder from capturing the inner CustomPaint
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: We don't need this comment every time. For most of these recorders we know we're using them to attempt to capture the full widget, which is why the subtree strategy is always .ignore

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, sure. It’s just because I added that comment in the first recorder I implemented, and I’ve been using the same structure ever since, so the comment ended up being carried over.

Comment on lines +30 to +39
typedef _SliderGeometry = ({
_SliderThumbGeometry thumb,
_SliderTrackSegmentGeometry inactiveTrack,
_SliderTrackSegmentGeometry activeTrack,
_SliderTrackSegmentGeometry? secondaryActiveTrack,
Rect? gap,
Rect? stopIndicator,
List<Rect> activeTickMarks,
List<Rect> inactiveTickMarks,
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a lot of stuff to put in a Record. Are we concerned about any downsides? I guess since it's mostly just being used as a return type that's probably okay, but it does seem like we're pushing it with this struct.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The slider widget is the most complex widget I’ve worked with so far. It includes several shapes and variants within the same component, so I introduced that structure to better organize the code.
That said, it’s true that I could implement the recorder without defining that structure. However, I would still need to return all the objects defined there anyway. I think the current solution preserves consistency, makes the intent clearer, and helps keep the codebase more readable and maintainable.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the question is whether to use a Record or a class with @immutable on it.

I looked it up, and Records saves some boilerplate and can have some advantages over a class in terms of memory management when they're small. But, they aren't gc allocated, so every time they're passed around you get an alloc / copy, so when they get big and are passed around frequently, they can cause performance problems.

This is likely fine because it's returned once then its individual properties are transferred, so the heap allocations should be kept to a minimum. But generally large Records like this should be avoided.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohhh okay, I understand now. Do you think it would still be better to change it to an immutable class, or since the allocations should remain minimal in this case, are you okay with keeping it as it is now?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm okay with keeping as is, since it's only used essentially as a return value. Just be aware of it in the future. If we're passing large Records around a lot it can be an easily avoidable performance drain.

Comment on lines +66 to +67
final widget = element.widget;
if (widget is! Slider) return null;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a faster rejection and should be put above the CupertinoSlider check.

);
} else {
thumbStyle = _SliderThumbStyle.round;
thumbSize = Size(20.0 * scale, 20.0 * scale);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of these naked numbers should be pulled out as constants.

Comment on lines +330 to +334
final double valueRatio = isMasked
? 0.5
: (range == 0
? 0.0
: ((widget.value - widget.min) / range).clamp(0.0, 1.0).toDouble());
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am still of the opinion this is easier to read.

final double valueRatio;
if (isMasked) {
  valueRatio = 0.5
} else {
  valueRatio = range == 0 ? 0.0 : (widget.value - widget.min / range).clamp(0.0, 1.0).toDouble();
}

Comment on lines +357 to +361
final double clampedSec =
secValue.clamp(widget.min, widget.max).toDouble();
final double secRatio =
range == 0 ? 0.0 : (clampedSec - widget.min) / range;
final double secX = trackLeft + trackWidth * secRatio;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no reason to specify these as doubles. Removing the type annotation and letting Dart treat them as num removes the unneeded toDouble after clamp, and everything internally should work just fine. You then only have to do the num to double conversion at the final set when it's used to create the Rect.

Additionally the extra clampedSec variable can be avoided with just:

final secValue = widget.secondaryTrackValue?.clamp(widget.min, widget.max);

Comment on lines +446 to +448
defaultColor = theme.colorScheme.onPrimary.withValues(alpha: 0.12);
} else if (year2023) {
defaultColor = theme.colorScheme.onSurface.withValues(alpha: 0.38);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're pulling these alpha values from somewhere, it might be good to say where.

Comment on lines +499 to +501
Color _findBackgroundColor(Element element, ThemeData theme) {
Color? result;
element.visitAncestorElements((ancestor) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of performing this backward tree walk and adding an extra render element, these containers should all render in SR just fine, and if we shorten the tracks, the gap should display as intended. Did we try that instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I tried that. I initially thought it was the cleaner and better solution, but setting year2023 to false is a bit tricky.
It works well most of the time, but near the edges, both on the left and right sides, the representation is not accurate. When the gap cuts off before the track can reach its full height, the visual output is incorrect. In fact, in that case it ends up inverting the track orientation.
Also, the wireframes don’t allow me to assign a custom border to each box individually, so the inner border representation is inaccurate as well.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 I'm curious about how inaccurate it is. This backward tree walk could end up going up quite a bit for minimal gain. We don't need to be 100% accurate in our drawing here, and if the end result is a fairly small gain, it may not be worth the extra processing time. I'll defer to @gonzalezreal on this one.

@JuanNaranjoDD JuanNaranjoDD requested a review from fuzzybinary May 19, 2026 13:12
@datadog-datadog-prod-us1

This comment has been minimized.

Comment on lines +563 to +565
// With maskAllInputs every thumb should be anchored at the track midpoint
// regardless of the supplied value (0.0, 0.5, 1.0 should all look the same).
final fixture = MaterialApp(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is specifically when a user asks to mask inputs by specifying .maskAllInputs either at the root or with a masking override. It looks like in that case iOS and Android only display the track and not the thumb, but confirm with @gonzalezreal.

Comment on lines +30 to +39
typedef _SliderGeometry = ({
_SliderThumbGeometry thumb,
_SliderTrackSegmentGeometry inactiveTrack,
_SliderTrackSegmentGeometry activeTrack,
_SliderTrackSegmentGeometry? secondaryActiveTrack,
Rect? gap,
Rect? stopIndicator,
List<Rect> activeTickMarks,
List<Rect> inactiveTickMarks,
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm okay with keeping as is, since it's only used essentially as a return value. Just be aware of it in the future. If we're passing large Records around a lot it can be an easily avoidable performance drain.

Comment on lines +499 to +501
Color _findBackgroundColor(Element element, ThemeData theme) {
Color? result;
element.visitAncestorElements((ancestor) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 I'm curious about how inaccurate it is. This backward tree walk could end up going up quite a bit for minimal gain. We don't need to be 100% accurate in our drawing here, and if the end result is a fairly small gain, it may not be worth the extra processing time. I'll defer to @gonzalezreal on this one.

@JuanNaranjoDD JuanNaranjoDD merged commit bdb14d6 into develop May 19, 2026
11 checks passed
@JuanNaranjoDD JuanNaranjoDD deleted the juan.naranjo/widget_support branch May 19, 2026 14:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants