Skip to content

Conversation

@LongCatIsLooong
Copy link
Contributor

@LongCatIsLooong LongCatIsLooong commented Oct 15, 2019

Description

Introduce CupertinoSlidingSegmentedControl, the iOS 13 CupertinoSegmentedControl.

segmented

Related Issues

Closes #33796

Tests

  • add packages/flutter/test/cupertino/sliding_segmented_control_test.dart

TODOs

  • momentary is missing: https://developer.apple.com/documentation/uikit/uisegmentedcontrol/1618586-ismomentary
  • Currently only supports equalWidth layout. proportional is missing.
  • The thumb shadow is done exactly as the sketch file describes, but it looks a bit off when compared with the native component.
  • The drag gesture recognizer has to be passed to the render object because it needs to know children's dimensions. We probably need a CupertinoMultiChildGestureDetector or something, as this kind of gesture is quite common on iOS.

Checklist

Before you create this PR confirm that it meets all requirements listed below by checking the relevant checkboxes ([x]). This will ensure a smooth and quick review process.

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I signed the CLA.
  • I read and followed the Flutter Style Guide, including Features we expect every widget to implement.
  • I updated/added relevant documentation (doc comments with ///).
  • All existing and new tests are passing.
  • The analyzer (flutter analyze --flutter-repo) does not report any problems on my PR.
  • I am willing to follow-up on review comments in a timely manner.

Breaking Change

Does your PR require Flutter developers to manually update their apps to accommodate your change?

  • No, this is not a breaking change.

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

Thanks for tackling this!

There are a few questions in my comments below that I wanted to clear up, and a few nitpicks, otherwise looks good.

Does this support dark mode by the way?

Copy link
Contributor

Choose a reason for hiding this comment

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

Could there be any problem here with HashMap being unordered? See https://master-api.flutter.dev/flutter/dart-core/Map-class.html. LinkedHashMap is ordered.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually I copied these from CupertinoSegmentedControl 😅 . The child widgets will be visually placed in the same order as they appear in the Map, if the Map is unordered then the children may appear in a different order every time the widget rebuilds.

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe add a disclaimer like I see elsewhere: The [children] argument must be an ordered [Map] such as a [LinkedHashMap]. I feel like it will be easier for people to end up with the order changing on them without understanding why.

Copy link
Contributor

Choose a reason for hiding this comment

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

This seems to be incomplete.

Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this a stateful widget with the children defined as a variable even though they never change?

Copy link
Contributor

Choose a reason for hiding this comment

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

Just for my own curiosity going forward, what is our plan for parameters like this versus theming?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I remember I had the same question and I vaguely remember Hans' answer was themes are complicated so we want to make a careful decision. @HansMuller did I just put words in your mouth?

Copy link
Contributor

Choose a reason for hiding this comment

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

The CupertinoTheme is pretty spare and I think that's a good thing. Providing widget parameters for a few visual customizations is also a good thing. Our primary goal is to look native by default. Enabling the developer to make sweeping changes to component types with the CupertinoTheme isn't really a goal.

Copy link
Contributor

Choose a reason for hiding this comment

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

I mentioned in another comment up above that there might be a concern about ordered vs. unordered Maps. Maybe just say the same thing up there as this says here, if needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You mean to mention such as a [LinkedHashMap]?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah the part about The [children] argument must be an ordered [Map] such as a [LinkedHashMap].. I'm responding on that comment above (#42775 (comment)).

/// See also:
///
/// * <https://developer.apple.com/design/human-interface-guidelines/ios/controls/segmented-controls/>
class CupertinoSlidingSegmentedControl<T> extends StatefulWidget {
Copy link
Contributor

Choose a reason for hiding this comment

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

What's the reason for keeping the old CupertinoSegmentedControl around and making this a new widget? Can you use both on native?

Actually though, I see that someone is requesting to still be able to use the old one: #33796 (comment)

Copy link
Contributor

Choose a reason for hiding this comment

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

Assuming that we are keeping both, is there any code that can be shared between the two, or are there tests that could cover both? I didn't look very closely, but just checking that you have tried to avoid code duplication as much as possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The reason for keeping both is the API of CupertinoSlidingSegmentedControl is drastically different from CupertinoSegmentedControl. It's probably too big of a breaking change (and breaks every existing CupertinoSegmentedControl) to introduce in one go. Introducing it as a new widget will allow people to migrate as they feel comfortable. But yeah we will have to maintain both widgets with duplicated code for the time being now.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah I see, makes sense. Is it worth adding a @deprecated flag to CupertinoSegmentedControl now?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

IMO for now it doesn't hurt for them to coexist, as the comment you mentioned requested.

}

// Play highlight animation for the child located at _highlightControllers[at].
void animateHighlightController({ T at, bool forward }) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this and maybe a few other methods could be private.

I guess it doesn't make much difference for instances of state, but just something I noticed.

// The other is the separator fadein/fadeout animation.
//
// 4. A tap down event on the segment pointed to by the current selected
// index will trigger a CABasciaAnimation that shrinks the thumb to 95% of its
Copy link
Contributor

Choose a reason for hiding this comment

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

Typo, "CABasciaAnimation" => "CABasicAnimation"

// original size, even if the sliding animation is still playing. The
/// corresponding tap up event inverts the process (eyeballed).
//
// 5. A tap down event on other segments will trigger a CABasciaAnimation
Copy link
Contributor

Choose a reason for hiding this comment

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

Typo on CABasciaAnimation again.


await tester.tap(find.byIcon(const IconData(1)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
Copy link
Contributor

Choose a reason for hiding this comment

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

Does pumpAndSettle not work?

@njovy
Copy link

njovy commented Oct 16, 2019

 @override
  Widget build(BuildContext context) {
    super.build(context);
    return CupertinoPageScaffold(
      child: CustomScrollView(slivers: <Widget>[
        CupertinoSliverNavigationBar(
          largeTitle: Text("Test"),
          middle: CupertinoSlidingSegmentedControl<int>(
            children: {
              0: Padding(
                padding: EdgeInsets.symmetric(horizontal: 24, vertical: 6),
                child: Text(
                  "Test1",
                  style: TextStyle(fontSize: 14),
                ),
              ),
              1: Padding(
                padding: EdgeInsets.symmetric(horizontal: 24, vertical: 6),
                child: Text("Test2", style: TextStyle(fontSize: 14)),
              ),
              2: Padding(
                padding: EdgeInsets.symmetric(horizontal: 24, vertical: 6),
                child: Text("Test3", style: TextStyle(fontSize: 14)),
              ),
            },
            controller: ValueNotifier<int>(0),
          ),
        ),
        SliverFillRemaining(
          child: Container(),
        )
      ]),
    );
  }

onTap is not called if I use this widget with a Sliver widget inside CustomScrollView
Above is a sample code that scrolling works but tapping.
Thank you for this rapid update!

@LongCatIsLooong LongCatIsLooong changed the title CupertinoSlidingSegmentedControl [WIP] CupertinoSlidingSegmentedControl Oct 17, 2019
Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

LGTM assuming @njovy's use case works.

Sorry if I spam you with mismatched comments and reviews, I'm failing at using Github right now...

@LongCatIsLooong LongCatIsLooong changed the title [WIP] CupertinoSlidingSegmentedControl CupertinoSlidingSegmentedControl Oct 18, 2019
@LongCatIsLooong
Copy link
Contributor Author

LongCatIsLooong commented Oct 18, 2019

@njovy Thank you so much for trying out the PR and catching the bug! I've updated the gesture handling so it works (seemingly) correctly in conjunction with a VerticalDragGestureRecognizer now.

@justinmc thanks for the review! Updated per your comments, I hope I didn't miss anything. (Turns out I did miss the dark mode comment. Yes it has dark mode support).

@njovy
Copy link

njovy commented Oct 18, 2019

@LongCatIsLooong It works like a charm with the latest commit. Thank you so much!


// The spring animation used when the thumb changes its rect.
final SpringSimulation _kThumbSpringAnimationSimulation = SpringSimulation(
const SpringDescription(mass: 1, stiffness: 503.551, damping: 44.8799),
Copy link
Contributor

Choose a reason for hiding this comment

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

The stiffness and damping values seem oddly specific. What are they based one?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I got those values from the native UISegmentedControl.

/// must be an ordered [Map] such as a [LinkedHashMap], the ordering of the keys
/// will determine the order of the widgets in the segmented control.
///
/// When the state of the segmented control changes, the widget changes [controller]'s
Copy link
Contributor

Choose a reason for hiding this comment

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

[controller]'s => the [controller]'s

/// control cease to be selected.
///
/// A segmented control can feature any [Widget] as one of the values in its
/// [Map] of [children]. The type T is the type of the keys used to identify each
Copy link
Contributor

Choose a reason for hiding this comment

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

The use of "keys" here might confuse developers who think we're referring to Key (it confused me). Even though the point is immediately clarified in the following text. Maybe say "[Map] keys" and just "map keys" after that.

Also: it would be helpful to explain, at the outset, that the map's key are segmented control's values.

/// 2: Text('Child 3'),
/// };
///
/// final ValueNotifier<int> controller = ValueNotifier<int>(null);
Copy link
Contributor

Choose a reason for hiding this comment

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

Might include a comment here like:

// null means no selection

Copy link
Contributor

Choose a reason for hiding this comment

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

The CupertinoTheme is pretty spare and I think that's a good thing. Providing widget parameters for a few visual customizations is also a good thing. Our primary goal is to look native by default. Enabling the developer to make sweeping changes to component types with the CupertinoTheme isn't really a goal.

@override
double computeMinIntrinsicWidth(double height) {
RenderBox child = firstChild;
double minWidth = 0.0;
Copy link
Contributor

Choose a reason for hiding this comment

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

Really maxMinChildWidth or widestMinChildWidth

double minWidth = 0.0;
while (child != null) {
final _SegmentedControlContainerBoxParentData childParentData = child.parentData;
final double childWidth = child.getMinIntrinsicWidth(height) + 2 * _kSegmentMinPadding;
Copy link
Contributor

Choose a reason for hiding this comment

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

Could just factor the _kSegmentMinPadding in at the end? Like:

return childCount * (widestMinChildWidth + 2 * _kSegmentMinPadding) + totalSeparatorWidth;

maxWidth = math.max(maxWidth, childWidth);
child = childParentData.nextSibling;
}
return maxWidth * childCount + totalSeparatorWidth;
Copy link
Contributor

Choose a reason for hiding this comment

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

Similar comments about var name and etc as above.


@override
double computeMinIntrinsicHeight(double width) {
RenderBox child = firstChild;
Copy link
Contributor

Choose a reason for hiding this comment

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

Could this be:

return getChildrenAsList.fold<double>(0, (double minHeight, RenderBox child) {
  return math.max(minHeight, child.getMinIntrinsicHeight(width));
});

Here and for computeMaxIntrinsicHeight().

Copy link
Contributor Author

Choose a reason for hiding this comment

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

getChildrenAsList's documentation says

/// This function is useful when you need random-access to the children of
/// this render object. If you're accessing the children in order, consider
/// walking the child list directly.

I think the imperative way is slightly more efficient?

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think the efficiency aspect will matter, but there's no harm in leaving this code as it is. It's pretty easy to read.

if (highlightedIndex != null) {
if (_childAnimations == null) {
_childAnimations = <RenderBox, _ChildAnimationManifest> { };
for (int i = 0; i < childCount - 1; i++) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Please use i += 1, here and elsewhere.

Copy link
Contributor

@HansMuller HansMuller left a comment

Choose a reason for hiding this comment

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

LGTM

expect(e.toString(), contains('children'));
}

final Map<int, Widget> children = <int, Widget>{};
Copy link
Contributor

Choose a reason for hiding this comment

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

FWIW you could write:

const Map<int, Widget> children = <int, Widget>{1: Text('Child 1'), 2: Text('Child 2')};

Here and below.

Future<void> verifyPadding({ EdgeInsets padding }) async {
final EdgeInsets effectivePadding = padding ?? const EdgeInsets.symmetric(vertical: 2, horizontal: 3);
final Rect segmentedControlRect = tester.getRect(find.byKey(key));

Copy link
Contributor

Choose a reason for hiding this comment

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

Extra blank line


final Map<int, Widget> children = <int, Widget>{};
children[0] = const SizedBox(
height: double.infinity,
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe include a comment about using height=infinity to ensure that the child consumes all of the available vertical space, like SizedBox.expand() does for both dimensions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

turns out I didn't need the SizedBox since I'm checking the Rect of the Opacity widget

(WidgetTester tester) async {
final Map<int, Widget> children = <int, Widget>{};
children[0] = const Text('Child 1');
children[1] = Container(
Copy link
Contributor

Choose a reason for hiding this comment

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

children[1] = SizedBox(width: 50, height: 50);

find.byKey(const ValueKey<String>('Segmented Control')),
);

expect(segmentedControl.size.width.isFinite, isTrue);
Copy link
Contributor

Choose a reason for hiding this comment

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

Couldn't we check the actual width of the segmented control here? Could simplify the expected width arithmetic by making the children SizedBox(width: 100) or something similar.


// Vertical drag works for the scroll view.
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Child 1')));
// The first moveBy doesn't actually move the scrollable. It's there to make
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for the explanation

testWidgets('Insert segment while animation is running', (WidgetTester tester) async {
final Map<int, Widget> children = SplayTreeMap<int, Widget>(
(int a, int b) => a - b,
(dynamic key) => true,
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this parameter needed?

@LongCatIsLooong LongCatIsLooong merged commit 9ea0ecc into flutter:master Oct 30, 2019
@LongCatIsLooong LongCatIsLooong deleted the segmented-control branch October 30, 2019 17:42
Inconnu08 pushed a commit to Inconnu08/flutter that referenced this pull request Nov 26, 2019
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Aug 3, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

f: cupertino flutter/packages/flutter/cupertino repository framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[iOS 13] segmented control is different

6 participants