diff --git a/examples/api/lib/cupertino/list_section/list_section_base.0.dart b/examples/api/lib/cupertino/list_section/list_section_base.0.dart new file mode 100644 index 00000000000..6c0bb8752e5 --- /dev/null +++ b/examples/api/lib/cupertino/list_section/list_section_base.0.dart @@ -0,0 +1,95 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Flutter code sample for base CupertinoListSection and CupertinoListTile. + +import 'package:flutter/cupertino.dart'; + +void main() => runApp(const CupertinoListSectionBaseApp()); + +class CupertinoListSectionBaseApp extends StatelessWidget { + const CupertinoListSectionBaseApp({super.key}); + + static const String _title = 'Flutter Code Sample'; + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + title: _title, + home: MyStatelessWidget(), + ); + } +} + +class MyStatelessWidget extends StatelessWidget { + const MyStatelessWidget({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: CupertinoListSection( + header: const Text('My Reminders'), + children: [ + CupertinoListTile( + title: const Text('Open pull request'), + leading: Container( + width: double.infinity, + height: double.infinity, + color: CupertinoColors.activeGreen, + ), + trailing: const CupertinoListTileChevron(), + onTap: () => Navigator.of(context).push( + CupertinoPageRoute( + builder: (BuildContext context) { + return const _SecondPage(text: 'Open pull request'); + }, + ), + ), + ), + CupertinoListTile( + title: const Text('Push to master'), + leading: Container( + width: double.infinity, + height: double.infinity, + color: CupertinoColors.systemRed, + ), + additionalInfo: const Text('Not available'), + ), + CupertinoListTile( + title: const Text('View last commit'), + leading: Container( + width: double.infinity, + height: double.infinity, + color: CupertinoColors.activeOrange, + ), + additionalInfo: const Text('12 days ago'), + trailing: const CupertinoListTileChevron(), + onTap: () => Navigator.of(context).push( + CupertinoPageRoute( + builder: (BuildContext context) { + return const _SecondPage(text: 'Last commit'); + }, + ), + ), + ), + ], + ), + ); + } +} + +class _SecondPage extends StatelessWidget { + const _SecondPage({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: Center( + child: Text(text), + ), + ); + } +} diff --git a/examples/api/lib/cupertino/list_section/list_section_inset.0.dart b/examples/api/lib/cupertino/list_section/list_section_inset.0.dart new file mode 100644 index 00000000000..c268c6d46e3 --- /dev/null +++ b/examples/api/lib/cupertino/list_section/list_section_inset.0.dart @@ -0,0 +1,95 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Flutter code sample for inset CupertinoListSection and CupertinoListTile. + +import 'package:flutter/cupertino.dart'; + +void main() => runApp(const CupertinoListSectionInsetApp()); + +class CupertinoListSectionInsetApp extends StatelessWidget { + const CupertinoListSectionInsetApp({super.key}); + + static const String _title = 'Flutter Code Sample'; + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + title: _title, + home: MyStatelessWidget(), + ); + } +} + +class MyStatelessWidget extends StatelessWidget { + const MyStatelessWidget({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: CupertinoListSection.insetGrouped( + header: const Text('My Reminders'), + children: [ + CupertinoListTile.notched( + title: const Text('Open pull request'), + leading: Container( + width: double.infinity, + height: double.infinity, + color: CupertinoColors.activeGreen, + ), + trailing: const CupertinoListTileChevron(), + onTap: () => Navigator.of(context).push( + CupertinoPageRoute( + builder: (BuildContext context) { + return const _SecondPage(text: 'Open pull request'); + }, + ), + ), + ), + CupertinoListTile.notched( + title: const Text('Push to master'), + leading: Container( + width: double.infinity, + height: double.infinity, + color: CupertinoColors.systemRed, + ), + additionalInfo: const Text('Not available'), + ), + CupertinoListTile.notched( + title: const Text('View last commit'), + leading: Container( + width: double.infinity, + height: double.infinity, + color: CupertinoColors.activeOrange, + ), + additionalInfo: const Text('12 days ago'), + trailing: const CupertinoListTileChevron(), + onTap: () => Navigator.of(context).push( + CupertinoPageRoute( + builder: (BuildContext context) { + return const _SecondPage(text: 'Last commit'); + }, + ), + ), + ), + ], + ), + ); + } +} + +class _SecondPage extends StatelessWidget { + const _SecondPage({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: Center( + child: Text(text), + ), + ); + } +} diff --git a/examples/api/test/cupertino/list_section/list_section_base.0_test.dart b/examples/api/test/cupertino/list_section/list_section_base.0_test.dart new file mode 100644 index 00000000000..dc736329fd9 --- /dev/null +++ b/examples/api/test/cupertino/list_section/list_section_base.0_test.dart @@ -0,0 +1,29 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_api_samples/cupertino/list_section/list_section_base.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Has exactly 1 CupertinoListSection base widget', (WidgetTester tester) async { + await tester.pumpWidget( + const example.CupertinoListSectionBaseApp(), + ); + + final Finder listSectionFinder = find.byType(CupertinoListSection); + expect(listSectionFinder, findsOneWidget); + + final CupertinoListSection listSectionWidget = tester.widget(listSectionFinder); + expect(listSectionWidget.type, equals(CupertinoListSectionType.base)); + }); + + testWidgets('CupertinoListSection has 3 CupertinoListTile children', (WidgetTester tester) async { + await tester.pumpWidget( + const example.CupertinoListSectionBaseApp(), + ); + + expect(find.byType(CupertinoListTile), findsNWidgets(3)); + }); +} diff --git a/examples/api/test/cupertino/list_section/list_section_inset.0_test.dart b/examples/api/test/cupertino/list_section/list_section_inset.0_test.dart new file mode 100644 index 00000000000..7bdc4946d0a --- /dev/null +++ b/examples/api/test/cupertino/list_section/list_section_inset.0_test.dart @@ -0,0 +1,29 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_api_samples/cupertino/list_section/list_section_inset.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Has exactly 1 CupertinoListSection inset grouped widget', (WidgetTester tester) async { + await tester.pumpWidget( + const example.CupertinoListSectionInsetApp(), + ); + + final Finder listSectionFinder = find.byType(CupertinoListSection); + expect(listSectionFinder, findsOneWidget); + + final CupertinoListSection listSectionWidget = tester.widget(listSectionFinder); + expect(listSectionWidget.type, equals(CupertinoListSectionType.insetGrouped)); + }); + + testWidgets('CupertinoListSection has 3 CupertinoListTile children', (WidgetTester tester) async { + await tester.pumpWidget( + const example.CupertinoListSectionInsetApp(), + ); + + expect(find.byType(CupertinoListTile), findsNWidgets(3)); + }); +} diff --git a/packages/flutter/lib/cupertino.dart b/packages/flutter/lib/cupertino.dart index 1938da10ba7..ca1dd34e8f3 100644 --- a/packages/flutter/lib/cupertino.dart +++ b/packages/flutter/lib/cupertino.dart @@ -38,6 +38,8 @@ export 'src/cupertino/form_section.dart'; export 'src/cupertino/icon_theme_data.dart'; export 'src/cupertino/icons.dart'; export 'src/cupertino/interface_level.dart'; +export 'src/cupertino/list_section.dart'; +export 'src/cupertino/list_tile.dart'; export 'src/cupertino/localizations.dart'; export 'src/cupertino/nav_bar.dart'; export 'src/cupertino/page_scaffold.dart'; diff --git a/packages/flutter/lib/src/cupertino/form_section.dart b/packages/flutter/lib/src/cupertino/form_section.dart index a1f0dda5756..6bd9fd4c104 100644 --- a/packages/flutter/lib/src/cupertino/form_section.dart +++ b/packages/flutter/lib/src/cupertino/form_section.dart @@ -5,28 +5,17 @@ import 'package:flutter/widgets.dart'; import 'colors.dart'; - -// Standard header margin, determined from SwiftUI's Forms in iOS 14.2 SDK. -const EdgeInsetsDirectional _kDefaultHeaderMargin = - EdgeInsetsDirectional.fromSTEB(20.0, 16.0, 20.0, 10.0); - -// Standard footer margin, determined from SwiftUI's Forms in iOS 14.2 SDK. -const EdgeInsetsDirectional _kDefaultFooterMargin = - EdgeInsetsDirectional.fromSTEB(20.0, 0.0, 20.0, 10.0); +import 'list_section.dart'; // Used for iOS "Inset Grouped" margin, determined from SwiftUI's Forms in // iOS 14.2 SDK. -const EdgeInsetsDirectional _kDefaultInsetGroupedRowsMargin = - EdgeInsetsDirectional.fromSTEB(20.0, 0.0, 20.0, 10.0); +const EdgeInsetsDirectional _kFormDefaultInsetGroupedRowsMargin = EdgeInsetsDirectional.fromSTEB(20.0, 0.0, 20.0, 10.0); -// Used for iOS "Inset Grouped" border radius, estimated from SwiftUI's Forms in -// iOS 14.2 SDK. -// TODO(edrisian): This should be a rounded rectangle once that shape is added. -const BorderRadius _kDefaultInsetGroupedBorderRadius = - BorderRadius.all(Radius.circular(10.0)); +// Standard header margin, determined from SwiftUI's Forms in iOS 14.2 SDK. +const EdgeInsetsDirectional _kFormDefaultHeaderMargin = EdgeInsetsDirectional.fromSTEB(20.0, 16.0, 20.0, 10.0); -// Used to differentiate the edge-to-edge section with the centered section. -enum _CupertinoFormSectionType { base, insetGrouped } +// Standard footer margin, determined from SwiftUI's Forms in iOS 14.2 SDK. +const EdgeInsetsDirectional _kFormDefaultFooterMargin = EdgeInsetsDirectional.fromSTEB(20.0, 0.0, 20.0, 10.0); /// An iOS-style form section. /// @@ -65,6 +54,12 @@ enum _CupertinoFormSectionType { base, insetGrouped } /// If null, defaults to [CupertinoColors.systemGroupedBackground]. /// /// {@macro flutter.material.Material.clipBehavior} +/// +/// See also: +/// +/// * [CupertinoFormRow], an iOS-style list tile, a typical child of +/// [CupertinoFormSection]. +/// * [CupertinoListSection], an iOS-style list section. class CupertinoFormSection extends StatelessWidget { /// Creates a section that mimics standard iOS forms. /// @@ -107,7 +102,7 @@ class CupertinoFormSection extends StatelessWidget { this.backgroundColor = CupertinoColors.systemGroupedBackground, this.decoration, this.clipBehavior = Clip.none, - }) : _type = _CupertinoFormSectionType.base, + }) : _type = CupertinoListSectionType.base, assert(children.length > 0); /// Creates a section that mimics standard "Inset Grouped" iOS forms. @@ -149,14 +144,14 @@ class CupertinoFormSection extends StatelessWidget { required this.children, this.header, this.footer, - this.margin = _kDefaultInsetGroupedRowsMargin, + this.margin = _kFormDefaultInsetGroupedRowsMargin, this.backgroundColor = CupertinoColors.systemGroupedBackground, this.decoration, this.clipBehavior = Clip.none, - }) : _type = _CupertinoFormSectionType.insetGrouped, + }) : _type = CupertinoListSectionType.insetGrouped, assert(children.length > 0); - final _CupertinoFormSectionType _type; + final CupertinoListSectionType _type; /// Sets the form section header. The section header lies above the /// [children] rows. @@ -203,116 +198,48 @@ class CupertinoFormSection extends StatelessWidget { @override Widget build(BuildContext context) { - final Color dividerColor = CupertinoColors.separator.resolveFrom(context); - final double dividerHeight = 1.0 / MediaQuery.of(context).devicePixelRatio; - - // Long divider is used for wrapping the top and bottom of rows. - // Only used in _CupertinoFormSectionType.base mode - final Widget longDivider = Container( - color: dividerColor, - height: dividerHeight, - ); - - // Short divider is used between rows. - // The value of the starting inset (15.0) is determined using SwiftUI's Form - // separators in the iOS 14.2 SDK. - final Widget shortDivider = Container( - margin: const EdgeInsetsDirectional.only(start: 15.0), - color: dividerColor, - height: dividerHeight, - ); - - // We construct childrenWithDividers as follows: - // Insert a short divider between all rows. - // If it is a `_CupertinoFormSectionType.base` type, add a long divider - // to the top and bottom of the rows. - assert(children.isNotEmpty); - - final List childrenWithDividers = []; - - if (_type == _CupertinoFormSectionType.base) { - childrenWithDividers.add(longDivider); - } - - children.sublist(0, children.length - 1).forEach((Widget widget) { - childrenWithDividers.add(widget); - childrenWithDividers.add(shortDivider); - }); - - childrenWithDividers.add(children.last); - if (_type == _CupertinoFormSectionType.base) { - childrenWithDividers.add(longDivider); - } - - final BorderRadius childrenGroupBorderRadius; - switch (_type) { - case _CupertinoFormSectionType.insetGrouped: - childrenGroupBorderRadius = _kDefaultInsetGroupedBorderRadius; - break; - case _CupertinoFormSectionType.base: - childrenGroupBorderRadius = BorderRadius.zero; - break; - } - - // Refactored the decorate children group in one place to avoid repeating it - // twice down bellow in the returned widget. - final DecoratedBox decoratedChildrenGroup = DecoratedBox( - decoration: decoration ?? BoxDecoration( - color: CupertinoDynamicColor.resolve( - decoration?.color ?? CupertinoColors.secondarySystemGroupedBackground, - context, - ), - borderRadius: childrenGroupBorderRadius, - ), - child: Column( - children: childrenWithDividers, - ), - ); - - return DecoratedBox( - decoration: BoxDecoration( - color: CupertinoDynamicColor.resolve(backgroundColor, context), - ), - child: Column( - children: [ - if (header != null) - Align( - alignment: AlignmentDirectional.centerStart, - child: DefaultTextStyle( - style: TextStyle( - fontSize: 13.0, - color: CupertinoColors.secondaryLabel.resolveFrom(context), - ), - child: Padding( - padding: _kDefaultHeaderMargin, - child: header, - ), - ), + final Widget? headerWidget = header == null + ? null + : DefaultTextStyle( + style: TextStyle( + fontSize: 13.0, + color: CupertinoColors.secondaryLabel.resolveFrom(context), ), - Padding( - padding: margin, - child: ClipRRect( - borderRadius: childrenGroupBorderRadius, - clipBehavior: clipBehavior, - child: decoratedChildrenGroup, + child: Padding( + padding: _kFormDefaultHeaderMargin, + child: header, + )); + + final Widget? footerWidget = footer == null + ? null + : DefaultTextStyle( + style: TextStyle( + fontSize: 13.0, + color: CupertinoColors.secondaryLabel.resolveFrom(context), ), - ), - if (footer != null) - Align( - alignment: AlignmentDirectional.centerStart, - child: DefaultTextStyle( - style: TextStyle( - fontSize: 13.0, - color: CupertinoColors.secondaryLabel.resolveFrom(context), - ), - child: Padding( - padding: _kDefaultFooterMargin, - child: footer, - ), - ), - ), - ], - ), - ); + child: Padding( + padding: _kFormDefaultFooterMargin, + child: footer, + )); + + return _type == CupertinoListSectionType.base + ? CupertinoListSection( + header: headerWidget, + footer: footerWidget, + margin: margin, + backgroundColor: backgroundColor, + decoration: decoration, + clipBehavior: clipBehavior, + hasLeading: false, + children: children) + : CupertinoListSection.insetGrouped( + header: headerWidget, + footer: footerWidget, + margin: margin, + backgroundColor: backgroundColor, + decoration: decoration, + clipBehavior: clipBehavior, + hasLeading: false, + children: children); } } diff --git a/packages/flutter/lib/src/cupertino/list_section.dart b/packages/flutter/lib/src/cupertino/list_section.dart new file mode 100644 index 00000000000..c7fdbb297d9 --- /dev/null +++ b/packages/flutter/lib/src/cupertino/list_section.dart @@ -0,0 +1,486 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'theme.dart'; + +// Margin on top of the list section. This was eyeballed from iOS 14.4 Simulator +// and should be always present on top of the edge-to-edge variant. +const double _kMarginTop = 22.0; + +// Standard header margin, determined from SwiftUI's Forms in iOS 14.2 SDK. +const EdgeInsetsDirectional _kDefaultHeaderMargin = EdgeInsetsDirectional.fromSTEB(20.0, 0.0, 20.0, 6.0); + +// Header margin for inset grouped variant, determined from iOS 14.4 Simulator. +const EdgeInsetsDirectional _kInsetGroupedDefaultHeaderMargin = EdgeInsetsDirectional.fromSTEB(20.0, 16.0, 20.0, 6.0); + +// Standard footer margin, determined from SwiftUI's Forms in iOS 14.2 SDK. +const EdgeInsetsDirectional _kDefaultFooterMargin = EdgeInsetsDirectional.fromSTEB(20.0, 0.0, 20.0, 0.0); + +// Footer margin for inset grouped variant, determined from iOS 14.4 Simulator. +const EdgeInsetsDirectional _kInsetGroupedDefaultFooterMargin = EdgeInsetsDirectional.fromSTEB(20.0, 0.0, 20.0, 10.0); + +// Margin around children in edge-to-edge variant, determined from iOS 14.4 +// Simulator. +const EdgeInsets _kDefaultRowsMargin = EdgeInsets.only(bottom: 8.0); + +// Used for iOS "Inset Grouped" margin, determined from SwiftUI's Forms in +// iOS 14.2 SDK. +const EdgeInsetsDirectional _kDefaultInsetGroupedRowsMargin = EdgeInsetsDirectional.fromSTEB(20.0, 20.0, 20.0, 10.0); + +// Used for iOS "Inset Grouped" margin, determined from SwiftUI's Forms in +// iOS 14.2 SDK. +const EdgeInsetsDirectional _kDefaultInsetGroupedRowsMarginWithHeader = EdgeInsetsDirectional.fromSTEB(20.0, 0.0, 20.0, 10.0); + +// Used for iOS "Inset Grouped" border radius, estimated from SwiftUI's Forms in +// iOS 14.2 SDK. +// TODO(edrisian): This should be a rounded rectangle once that shape is added. +const BorderRadius _kDefaultInsetGroupedBorderRadius = BorderRadius.all(Radius.circular(10.0)); + +// The margin of divider used in base list section. Estimated from iOS 14.4 SDK +// Settings app. +const double _kBaseDividerMargin = 20.0; + +// Additional margin of divider used in base list section with list tiles with +// leading widgets. Estimated from iOS 14.4 SDK Settings app. +const double _kBaseAdditionalDividerMargin = 44.0; + +// The margin of divider used in inset grouped version of list section. +// Estimated from iOS 14.4 SDK Reminders app. +const double _kInsetDividerMargin = 14.0; + +// Additional margin of divider used in inset grouped version of list section. +// Estimated from iOS 14.4 SDK Reminders app. +const double _kInsetAdditionalDividerMargin = 42.0; + +// Additional margin of divider used in inset grouped version of list section +// when there is no leading widgets. Estimated from iOS 14.4 SDK Notes app. +const double _kInsetAdditionalDividerMarginWithoutLeading = 14.0; + +// Color of header and footer text in edge-to-edge variant. +const Color _kHeaderFooterColor = CupertinoDynamicColor( + color: Color.fromRGBO(108, 108, 108, 1.0), + darkColor: Color.fromRGBO(142, 142, 146, 1.0), + highContrastColor: Color.fromRGBO(74, 74, 77, 1.0), + darkHighContrastColor: Color.fromRGBO(176, 176, 183, 1.0), + elevatedColor: Color.fromRGBO(108, 108, 108, 1.0), + darkElevatedColor: Color.fromRGBO(142, 142, 146, 1.0), + highContrastElevatedColor: Color.fromRGBO(108, 108, 108, 1.0), + darkHighContrastElevatedColor: Color.fromRGBO(142, 142, 146, 1.0), +); + +/// Denotes what type of the list section a [CupertinoListSection] is. +/// +/// This is for internal use only. +enum CupertinoListSectionType { + /// A basic form of [CupertinoListSection]. + base, + + /// An inset-grouped style of [CupertinoListSection]. + insetGrouped, +} + +/// An iOS-style list section. +/// +/// The [CupertinoListSection] is a container for children widgets. These are +/// most often [CupertinoListTile]s. +/// +/// The base constructor for [CupertinoListSection] constructs an +/// edge-to-edge style section which includes an iOS-style header, the dividers +/// between rows, and borders on top and bottom of the rows. An example of such +/// list section are sections in iOS Settings app. +/// +/// The [CupertinoListSection.insetGrouped] constructor creates a round-edged +/// and padded section that is seen in iOS Notes and Reminders apps. It creates +/// an iOS-style header, and the dividers between rows. Does not create borders +/// on top and bottom of the rows. +/// +/// The section [header] lies above the [children] rows, with margins and style +/// that match the iOS style. +/// +/// The section [footer] lies below the [children] rows and is used to provide +/// additional information for current list section. +/// +/// The [children] is the list of widgets to be displayed in this list section. +/// Typically, the children are of type [CupertinoListTile], however these is +/// not enforced. +/// +/// The [margin] is used to provide spacing around the content area of the +/// section encapsulating [children]. +/// +/// The [decoration] of [children] specifies how they should be decorated. If it +/// is not provided in constructor, the background color of [children] defaults +/// to [CupertinoColors.secondarySystemGroupedBackground] and border radius of +/// children group defaults to 10.0 circular radius when constructing with +/// [CupertinoListSection.insetGrouped]. Defaults to zero radius for the +/// standard [CupertinoListSection] constructor. +/// +/// The [dividerMargin] and [additionalDividerMargin] specify the starting +/// margin of the divider between list tiles. The [dividerMargin] is always +/// present, but [additionalDividerMargin] is only added to the [dividerMargin] +/// if `hasLeading` is set to true in the constructor, which is the default +/// value. +/// +/// The [backgroundColor] of the section defaults to +/// [CupertinoColors.systemGroupedBackground]. +/// +/// {@macro flutter.material.Material.clipBehavior} +/// +/// {@tool dartpad} +/// Creates a base [CupertinoListSection] containing [CupertinoListTile]s with +/// `leading`, `title`, `additionalInfo` and `trailing` widgets. +/// +/// ** See code in examples/api/lib/cupertino/list_section/list_section_base.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// Creates an "Inset Grouped" [CupertinoListSection] containing +/// notched [CupertinoListTile]s with `leading`, `title`, `additionalInfo` and +/// `trailing` widgets. +/// +/// ** See code in examples/api/lib/cupertino/list_section/list_section_inset.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoListTile], an iOS-style list tile, a typical child of +/// [CupertinoListSection]. +/// * [CupertinoFormSection], an iOS-style form section. +class CupertinoListSection extends StatelessWidget { + /// Creates a section that mimics standard iOS forms. + /// + /// The base constructor for [CupertinoListSection] constructs an + /// edge-to-edge style section which includes an iOS-style header, the dividers + /// between rows, and borders on top and bottom of the rows. An example of such + /// list section are sections in iOS Settings app. + /// + /// The [header] parameter sets the form section header. The section header + /// lies above the [children] rows, with margins that match the iOS style. + /// + /// The [footer] parameter sets the form section footer. The section footer + /// lies below the [children] rows. + /// + /// The [children] parameter is required and sets the list of rows shown in + /// the section. The [children] parameter takes a list, as opposed to a more + /// efficient builder function that lazy builds, because forms are intended to + /// be short in row count. It is recommended that only [CupertinoFormRow] and + /// [CupertinoTextFormFieldRow] widgets be included in the [children] list in + /// order to retain the iOS look. + /// + /// The [margin] parameter sets the spacing around the content area of the + /// section encapsulating [children], and defaults to zero padding. + /// + /// The [decoration] parameter sets the decoration around [children]. + /// If null, defaults to [CupertinoColors.secondarySystemGroupedBackground]. + /// If null, defaults to 10.0 circular radius when constructing with + /// [CupertinoListSection.insetGrouped]. Defaults to zero radius for the + /// standard [CupertinoListSection] constructor. + /// + /// The [backgroundColor] parameter sets the background color behind the + /// section. If null, defaults to [CupertinoColors.systemGroupedBackground]. + /// + /// The [dividerMargin] parameter sets the starting offset of the divider + /// between rows. + /// + /// The [additionalDividerMargin] parameter adds additional margin to existing + /// [dividerMargin] when [hasLeading] is set to true. By default, it offsets + /// for the width of leading and space between leading and title of + /// [CupertinoListTile], but it can be overwritten for custom look. + /// + /// The [hasLeading] parameter specifies whether children [CupertinoListTile] + /// widgets contain leading or not. Used for calculating correct starting + /// margin for the divider between rows. + /// + /// The [topMargin] is used to specify the margin above the list section. It + /// matches the iOS look by default. + /// + /// {@macro flutter.material.Material.clipBehavior} + const CupertinoListSection({ + super.key, + this.children, + this.header, + this.footer, + this.margin = _kDefaultRowsMargin, + this.backgroundColor = CupertinoColors.systemGroupedBackground, + this.decoration, + this.clipBehavior = Clip.none, + this.dividerMargin = _kBaseDividerMargin, + double? additionalDividerMargin, + this.topMargin = _kMarginTop, + bool hasLeading = true, + }) : assert((children != null && children.length > 0) || header != null), + type = CupertinoListSectionType.base, + additionalDividerMargin = additionalDividerMargin ?? + (hasLeading ? _kBaseAdditionalDividerMargin : 0.0); + + /// Creates a section that mimicks standard "Inset Grouped" iOS list section. + /// + /// The [CupertinoListSection.insetGrouped] constructor creates a round-edged + /// and padded section that is seen in iOS Notes and Reminders apps. It creates + /// an iOS-style header, and the dividers between rows. Does not create borders + /// on top and bottom of the rows. + /// + /// The [header] parameter sets the form section header. The section header + /// lies above the [children] rows, with margins that match the iOS style. + /// + /// The [footer] parameter sets the form section footer. The section footer + /// lies below the [children] rows. + /// + /// The [children] parameter is required and sets the list of rows shown in + /// the section. The [children] parameter takes a list, as opposed to a more + /// efficient builder function that lazy builds, because forms are intended to + /// be short in row count. It is recommended that only [CupertinoListTile] + /// widget be included in the [children] list in order to retain the iOS look. + /// + /// The [margin] parameter sets the spacing around the content area of the + /// section encapsulating [children], and defaults to the standard + /// notched-style iOS form padding. + /// + /// The [decoration] parameter sets the decoration around [children]. + /// If null, defaults to [CupertinoColors.secondarySystemGroupedBackground]. + /// If null, defaults to 10.0 circular radius when constructing with + /// [CupertinoListSection.insetGrouped]. Defaults to zero radius for the + /// standard [CupertinoListSection] constructor. + /// + /// The [backgroundColor] parameter sets the background color behind the + /// section. If null, defaults to [CupertinoColors.systemGroupedBackground]. + /// + /// The [dividerMargin] parameter sets the starting offset of the divider + /// between rows. + /// + /// The [additionalDividerMargin] parameter adds additional margin to existing + /// [dividerMargin] when [hasLeading] is set to true. By default, it offsets + /// for the width of leading and space between leading and title of + /// [CupertinoListTile], but it can be overwritten for custom look. + /// + /// The [hasLeading] parameter specifies whether children [CupertinoListTile] + /// widgets contain leading or not. Used for calculating correct starting + /// margin for the divider between rows. + /// + /// {@macro flutter.material.Material.clipBehavior} + const CupertinoListSection.insetGrouped({ + super.key, + this.children, + this.header, + this.footer, + EdgeInsetsGeometry? margin, + this.backgroundColor = CupertinoColors.systemGroupedBackground, + this.decoration, + this.clipBehavior = Clip.hardEdge, + this.dividerMargin = _kInsetDividerMargin, + double? additionalDividerMargin, + this.topMargin, + bool hasLeading = true, + }) : assert((children != null && children.length > 0) || header != null), + type = CupertinoListSectionType.insetGrouped, + additionalDividerMargin = additionalDividerMargin ?? + (hasLeading + ? _kInsetAdditionalDividerMargin + : _kInsetAdditionalDividerMarginWithoutLeading), + margin = margin ?? (header == null ? _kDefaultInsetGroupedRowsMargin : _kDefaultInsetGroupedRowsMarginWithHeader); + + /// The type of list section, either base or inset grouped. + /// + /// This member is public for testing purposes only and cannot be set + /// manually. Instead, use a corresponding constructors. + @visibleForTesting + final CupertinoListSectionType type; + + /// Sets the form section header. The section header lies above the [children] + /// rows. Usually a [Text] widget. + final Widget? header; + + /// Sets the form section footer. The section footer lies below the [children] + /// rows. Usually a [Text] widget. + final Widget? footer; + + /// Margin around the content area of the section encapsulating [children]. + /// + /// Defaults to zero padding if constructed with standard + /// [CupertinoListSection] constructor. Defaults to the standard notched-style + /// iOS margin when constructing with [CupertinoListSection.insetGrouped]. + final EdgeInsetsGeometry margin; + + /// The list of rows in the section. Usually a list of [CupertinoListTile]s. + /// + /// This takes a list, as opposed to a more efficient builder function that + /// lazy builds, because such lists are intended to be short in row count. + /// It is recommended that only [CupertinoListTile] widget be included in the + /// [children] list in order to retain the iOS look. + final List? children; + + /// Sets the decoration around [children]. + /// + /// If null, background color defaults to + /// [CupertinoColors.secondarySystemGroupedBackground]. + /// + /// If null, border radius defaults to 10.0 circular radius when constructing + /// with [CupertinoListSection.insetGrouped]. Defaults to zero radius for the + /// standard [CupertinoListSection] constructor. + final BoxDecoration? decoration; + + /// Sets the background color behind the section. + /// + /// Defaults to [CupertinoColors.systemGroupedBackground]. + final Color backgroundColor; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + /// The starting offset of a margin between two list tiles. + final double dividerMargin; + + /// Additional starting inset of the divider used between rows. This is used + /// when adding a leading icon to children and a divider should start at the + /// text inset instead of the icon. + final double additionalDividerMargin; + + /// Margin above the list section. Only used in edge-to-edge variant and it + /// matches iOS style by default. + final double? topMargin; + + @override + Widget build(BuildContext context) { + final Color dividerColor = CupertinoColors.separator.resolveFrom(context); + final double dividerHeight = 1.0 / MediaQuery.of(context).devicePixelRatio; + + // Long divider is used for wrapping the top and bottom of rows. + // Only used in CupertinoListSectionType.base mode. + final Widget longDivider = Container( + color: dividerColor, + height: dividerHeight, + ); + + // Short divider is used between rows. + final Widget shortDivider = Container( + margin: EdgeInsetsDirectional.only( + start: dividerMargin + additionalDividerMargin), + color: dividerColor, + height: dividerHeight, + ); + + Widget? headerWidget; + if (header != null) { + headerWidget = DefaultTextStyle( + style: CupertinoTheme.of(context).textTheme.textStyle.merge( + type == CupertinoListSectionType.base + ? TextStyle( + fontSize: 13.0, + color: CupertinoDynamicColor.resolve( + _kHeaderFooterColor, context)) + : const TextStyle( + fontSize: 20.0, fontWeight: FontWeight.bold), + ), + child: header!, + ); + } + + Widget? footerWidget; + if (footer != null) { + footerWidget = DefaultTextStyle( + style: type == CupertinoListSectionType.base + ? CupertinoTheme.of(context).textTheme.textStyle.merge(TextStyle( + fontSize: 13.0, + color: CupertinoDynamicColor.resolve( + _kHeaderFooterColor, context), + )) + : CupertinoTheme.of(context).textTheme.textStyle, + child: footer!, + ); + } + + BorderRadius? childrenGroupBorderRadius; + DecoratedBox? decoratedChildrenGroup; + if (children != null && children!.isNotEmpty) { + // We construct childrenWithDividers as follows: + // Insert a short divider between all rows. + // If it is a `CupertinoListSectionType.base` type, add a long divider + // to the top and bottom of the rows. + final List childrenWithDividers = []; + + if (type == CupertinoListSectionType.base) { + childrenWithDividers.add(longDivider); + } + + children!.sublist(0, children!.length - 1).forEach((Widget widget) { + childrenWithDividers.add(widget); + childrenWithDividers.add(shortDivider); + }); + + childrenWithDividers.add(children!.last); + if (type == CupertinoListSectionType.base) { + childrenWithDividers.add(longDivider); + } + + switch (type) { + case CupertinoListSectionType.insetGrouped: + childrenGroupBorderRadius = _kDefaultInsetGroupedBorderRadius; + break; + case CupertinoListSectionType.base: + childrenGroupBorderRadius = BorderRadius.zero; + break; + } + + // Refactored the decorate children group in one place to avoid repeating it + // twice down bellow in the returned widget. + decoratedChildrenGroup = DecoratedBox( + decoration: decoration ?? + BoxDecoration( + color: CupertinoDynamicColor.resolve( + decoration?.color ?? + CupertinoColors.secondarySystemGroupedBackground, + context), + borderRadius: childrenGroupBorderRadius, + ), + child: Column(children: childrenWithDividers), + ); + } + + return DecoratedBox( + decoration: BoxDecoration( + color: CupertinoDynamicColor.resolve(backgroundColor, context)), + child: Column( + children: [ + if (type == CupertinoListSectionType.base) + SizedBox(height: topMargin), + if (headerWidget != null) + Align( + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: type == CupertinoListSectionType.base + ? _kDefaultHeaderMargin + : _kInsetGroupedDefaultHeaderMargin, + child: headerWidget, + ), + ), + if (children != null && children!.isNotEmpty) + Padding( + padding: margin, + child: clipBehavior == Clip.none + ? decoratedChildrenGroup + : ClipRRect( + borderRadius: childrenGroupBorderRadius, + clipBehavior: clipBehavior, + child: decoratedChildrenGroup, + ), + ), + if (footerWidget != null) + Align( + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: type == CupertinoListSectionType.base + ? _kDefaultFooterMargin + : _kInsetGroupedDefaultFooterMargin, + child: footerWidget, + ), + ), + ], + ), + ); + } +} diff --git a/packages/flutter/lib/src/cupertino/list_tile.dart b/packages/flutter/lib/src/cupertino/list_tile.dart new file mode 100644 index 00000000000..de224dd1f6f --- /dev/null +++ b/packages/flutter/lib/src/cupertino/list_tile.dart @@ -0,0 +1,414 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'icons.dart'; +import 'theme.dart'; + +// These constants were eyeballed from iOS 14.4 Settings app for base, Notes for +// notched without leading, and Reminders app for notched with leading. +const double _kLeadingSize = 28.0; +const double _kNotchedLeadingSize = 30.0; +const double _kMinHeight = _kLeadingSize + 2 * 8.0; +const double _kMinHeightWithSubtitle = _kLeadingSize + 2 * 10.0; +const double _kNotchedMinHeight = _kNotchedLeadingSize + 2 * 12.0; +const double _kNotchedMinHeightWithoutLeading = _kNotchedLeadingSize + 2 * 10.0; +const EdgeInsetsDirectional _kPadding = EdgeInsetsDirectional.only(start: 20.0, end: 14.0); +const EdgeInsetsDirectional _kPaddingWithSubtitle = EdgeInsetsDirectional.only(start: 20.0, end: 14.0); +const EdgeInsets _kNotchedPadding = EdgeInsets.symmetric(horizontal: 14.0); +const EdgeInsetsDirectional _kNotchedPaddingWithoutLeading = EdgeInsetsDirectional.fromSTEB(28.0, 10.0, 14.0, 10.0); +const double _kLeadingToTitle = 16.0; +const double _kNotchedLeadingToTitle = 12.0; +const double _kNotchedTitleToSubtitle = 3.0; +const double _kAdditionalInfoToTrailing = 6.0; +const double _kNotchedTitleWithSubtitleFontSize = 16.0; +const double _kSubtitleFontSize = 12.0; +const double _kNotchedSubtitleFontSize = 14.0; + +enum _CupertinoListTileType { base, notched } + +/// An iOS-style list tile. +/// +/// The [CupertinoListTile] is a Cupertino equivalent of Material [ListTile]. +/// It comes in two forms, an old-fashioned edge-to-edge variant known from iOS +/// Settings app and in a new, "Inset Grouped" form, known from either iOS Notes +/// or Reminders app. The first is constructed using default constructor, and +/// the latter using named constructor [CupertinoListTile.notched]. +/// +/// The [title], [subtitle], and [additionalInfo] are usually [Text] widgets. +/// They are all limited to one line so it is a responsibility of the caller to +/// take care of text wrapping. +/// +/// The size of [leading] is by default constrained to match the iOS size, +/// depending of the type of list tile. This can however be overriden by +/// providing [leadingSize]. The [trailing] widget is not constrained and is +/// therefore a responsibility of the caller to ensure reasonable size of the +/// [trailing] widget. +/// +/// The background color of the tile can be set with [backgroundColor] for the +/// state before tile was tapped and with [backgroundColorActivated] for the +/// state after the tile was tapped. By default, both values are set to match +/// the default iOS appearance. +/// +/// The [padding] and [leadingToTitle] are by default set to match iOS but can +/// be overwritten if necessary. +/// +/// The [onTap] callback provides an option to react to taps anywhere inside the +/// list tile. This can be used to navigate routes and according to iOS +/// behaviour it should not be used for example to toggle the [CupertinoSwitch] +/// in the trailing widget. +/// +/// See also: +/// +/// * [CupertinoListSection], an iOS-style list that is a typical container for +/// [CupertinoListTile]. +/// * [ListTile], a Material Design list tile. +class CupertinoListTile extends StatefulWidget { + /// Creates an edge-to-edge iOS-style list tile like the tiles in iOS Settings + /// app. + /// + /// The [title] parameter is required. It is used to convey the most important + /// information of list tile. It is typically a [Text]. + /// + /// The [subtitle] parameter is used to display additional information. It is + /// placed below the [title]. + /// + /// The [additionalInfo] parameter is used to display additional information. + /// It is placed at the end of the tile, before the [trailing] if supplied. + /// + /// The [leading] parameter is typically an [Icon] or an [Image] and it comes + /// at the start of the tile. If omitted in all list tiles, a `hasLeading` of + /// enclosing [CupertinoListSection] should be set to `false` to ensure + /// correct margin of divider between tiles. + /// + /// The [trailing] parameter is typically a [CupertinoListTileChevron], an + /// [Icon], or a [CupertinoButton]. It is placed at the very end of the tile. + /// + /// The [onTap] parameter is used to provide an action that is called when the + /// tile is tapped. It is mainly used for navigating to a new route. It should + /// not be used to toggle a trailing [CupertinoSwitch] and similar usecases + /// because when tile is tapped, it switches the background color and remains + /// changed. This is according to iOS behaviour. + /// + /// The [backgroundColor] provides a custom background color for the tile in + /// a state before tapped. By default, it matches the theme's background color + /// which is by default a [CupertinoColors.systemBackground]. + /// + /// The [backgroundColorActivated] provides a custom background color for the + /// tile after it was tapped. By default, it matches the theme's background + /// color which is by default a [CupertinoColors.systemGrey4]. + /// + /// The [padding] parameter sets the padding of the content inside the tile. + /// It defaults to a value that matches the iOS look, depending on a type of + /// [CupertinoListTile]. For native look, it should not be provided. + /// + /// The [leadingSize] constrains the width and height of the leading widget. + /// By default, it is set to a value that matches the iOS look, depending on a + /// type of [CupertinoListTile]. For native look, it should not be provided. + /// + /// The [leadingToTitle] specifies the horizontal space between [leading] and + /// [title] widgets. By default, it is set to a value that matched the iOS + /// look, depending on a type of [CupertinoListTile]. For native look, it + /// should not be provided. + const CupertinoListTile({ + super.key, + required this.title, + this.subtitle, + this.additionalInfo, + this.leading, + this.trailing, + this.onTap, + this.backgroundColor, + this.backgroundColorActivated, + this.padding, + this.leadingSize = _kLeadingSize, + this.leadingToTitle = _kLeadingToTitle, + }) : _type = _CupertinoListTileType.base; + + /// Creates a notched iOS-style list tile like the tiles in iOS Notes app or + /// Reminders app. + /// + /// The [title] parameter is required. It is used to convey the most important + /// information of list tile. It is typically a [Text]. + /// + /// The [subtitle] parameter is used to display additional information. It is + /// placed below the [title]. + /// + /// The [additionalInfo] parameter is used to display additional information. + /// It is placed at the end of the tile, before the [trailing] if supplied. + /// + /// The [leading] parameter is typically an [Icon] or an [Image] and it comes + /// at the start of the tile. If omitted in all list tiles, a `hasLeading` of + /// enclosing [CupertinoListSection] should be set to `false` to ensure + /// correct margin of divider between tiles. For Notes-like tile appearance, + /// the [leading] can be left `null`. + /// + /// The [trailing] parameter is typically a [CupertinoListTileChevron], an + /// [Icon], or a [CupertinoButton]. It is placed at the very end of the tile. + /// For Notes-like tile appearance, the [trailing] can be left `null`. + /// + /// The [onTap] parameter is used to provide an action that is called when the + /// tile is tapped. It is mainly used for navigating to a new route. It should + /// not be used to toggle a trailing [CupertinoSwitch] and similar usecases + /// because when tile is tapped, it switches the background color and remains + /// changed. This is according to iOS behaviour. + /// + /// The [backgroundColor] provides a custom background color for the tile in + /// a state before tapped. By default, it matches the theme's background color + /// which is by default a [CupertinoColors.systemBackground]. + /// + /// The [backgroundColorActivated] provides a custom background color for the + /// tile after it was tapped. By default, it matches the theme's background + /// color which is by default a [CupertinoColors.systemGrey4]. + /// + /// The [padding] parameter sets the padding of the content inside the tile. + /// It defaults to a value that matches the iOS look, depending on a type of + /// [CupertinoListTile]. For native look, it should not be provided. + /// + /// The [leadingSize] constrains the width and height of the leading widget. + /// By default, it is set to a value that matches the iOS look, depending on a + /// type of [CupertinoListTile]. For native look, it should not be provided. + /// + /// The [leadingToTitle] specifies the horizontal space between [leading] and + /// [title] widgets. By default, it is set to a value that matched the iOS + /// look, depending on a type of [CupertinoListTile]. For native look, it + /// should not be provided. + const CupertinoListTile.notched({ + super.key, + required this.title, + this.subtitle, + this.additionalInfo, + this.leading, + this.trailing, + this.onTap, + this.backgroundColor, + this.backgroundColorActivated, + this.padding, + this.leadingSize = _kNotchedLeadingSize, + this.leadingToTitle = _kNotchedLeadingToTitle, + }) : _type = _CupertinoListTileType.notched; + + final _CupertinoListTileType _type; + + /// A [title] is used to convey the central information. Usually a [Text]. + final Widget title; + + /// A [subtitle] is used to display additional information. It is located + /// below [title]. Usually a [Text] widget. + final Widget? subtitle; + + /// Similar to [subtitle], an [additionalInfo] is used to display additional + /// information. However, instead of being displayed below [title], it is + /// displayed on the right, before [trailing]. Usually a [Text] widget. + final Widget? additionalInfo; + + /// A widget displayed at the start of the [CupertinoListTile]. This is + /// typically an `Icon` or an `Image`. + final Widget? leading; + + /// A widget displayed at the end of the [CupertinoListTile]. This is usually + /// a right chevron icon (e.g. `CupertinoListTileChevron`), or an `Icon`. + final Widget? trailing; + + /// The [onTap] function is called when a user taps on [CupertinoListTile]. If + /// left `null`, the [CupertinoListTile] will not react on taps. If this is a + /// `Future Function()`, then the [CupertinoListTile] remains activated + /// until the returned future is awaited. This is according to iOS behaviour. + /// However, if this function is a `void Function()`, then the tile is active + /// only for the duration of invocation. + final FutureOr Function()? onTap; + + /// The [backgroundColor] of the tile in normal state. Once the tile is + /// tapped, the background color switches to [backgroundColorActivated]. It is + /// set to match the iOS look by default. + final Color? backgroundColor; + + /// The [backgroundColorActivated] is the background color of the tile after + /// the tile was tapped. It is set to match the iOS look by default. + final Color? backgroundColorActivated; + + /// Padding of the content inside [CupertinoListTile]. + final EdgeInsetsGeometry? padding; + + /// The [leadingSize] is used to constrain the width and height of [leading] + /// widget. + final double leadingSize; + + /// The horizontal space between [leading] widget and [title]. + final double leadingToTitle; + + @override + State createState() => _CupertinoListTileState(); +} + +class _CupertinoListTileState extends State { + bool _tapped = false; + + @override + Widget build(BuildContext context) { + final TextStyle titleTextStyle = + widget._type == _CupertinoListTileType.base || widget.subtitle == null + ? CupertinoTheme.of(context).textTheme.textStyle + : CupertinoTheme.of(context).textTheme.textStyle.merge( + TextStyle( + fontWeight: FontWeight.w600, + fontSize: widget.leading == null ? _kNotchedTitleWithSubtitleFontSize : null, + ), + ); + + final TextStyle subtitleTextStyle = widget._type == _CupertinoListTileType.base + ? CupertinoTheme.of(context).textTheme.textStyle.merge( + TextStyle( + fontSize: _kSubtitleFontSize, + color: CupertinoColors.secondaryLabel.resolveFrom(context), + ), + ) + : CupertinoTheme.of(context).textTheme.textStyle.merge( + TextStyle( + fontSize: _kNotchedSubtitleFontSize, + color: CupertinoColors.secondaryLabel.resolveFrom(context), + ), + ); + + final TextStyle? additionalInfoTextStyle = widget.additionalInfo != null + ? CupertinoTheme.of(context).textTheme.textStyle.merge( + TextStyle(color: CupertinoColors.secondaryLabel.resolveFrom(context))) + : null; + + final Widget title = DefaultTextStyle( + style: titleTextStyle, + maxLines: 1, + child: widget.title, + ); + + EdgeInsetsGeometry? padding = widget.padding; + if (padding == null) { + switch (widget._type) { + case _CupertinoListTileType.base: + padding = widget.subtitle == null ? _kPadding : _kPaddingWithSubtitle; + break; + case _CupertinoListTileType.notched: + padding = widget.leading == null ? _kNotchedPaddingWithoutLeading : _kNotchedPadding; + break; + } + } + + Widget? subtitle; + if (widget.subtitle != null) { + subtitle = DefaultTextStyle( + style: subtitleTextStyle, + maxLines: 1, + child: widget.subtitle!, + ); + } + + Widget? additionalInfo; + if (widget.additionalInfo != null) { + additionalInfo = DefaultTextStyle( + style: additionalInfoTextStyle!, + maxLines: 1, + child: widget.additionalInfo!, + ); + } + + // The color for default state tile is set to either what user provided or + // null and it will resolve to the correct color provided by context. But if + // the tile was tapped, it is set to what user provided or if null to the + // default color that matched the iOS-style. + Color? backgroundColor = widget.backgroundColor; + if (_tapped) { + backgroundColor = widget.backgroundColorActivated ?? CupertinoColors.systemGrey4.resolveFrom(context); + } + + double minHeight; + switch (widget._type) { + case _CupertinoListTileType.base: + minHeight = subtitle == null ? _kMinHeight : _kMinHeightWithSubtitle; + break; + case _CupertinoListTileType.notched: + minHeight = widget.leading == null ? _kNotchedMinHeightWithoutLeading : _kNotchedMinHeight; + break; + } + + final Widget child = Container( + constraints: BoxConstraints(minWidth: double.infinity, minHeight: minHeight), + color: backgroundColor, + child: Padding( + padding: padding, + child: Row( + children: [ + if (widget.leading != null) ...[ + SizedBox( + width: widget.leadingSize, + height: widget.leadingSize, + child: Center( + child: widget.leading, + ), + ), + SizedBox(width: widget.leadingToTitle), + ] else + SizedBox(height: widget.leadingSize), + Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + title, + if (subtitle != null) ...[ + const SizedBox(height: _kNotchedTitleToSubtitle), + subtitle, + ], + ], + ), + const Spacer(), + if (additionalInfo != null) ...[ + additionalInfo, + if (widget.trailing != null) + const SizedBox(width: _kAdditionalInfoToTrailing), + ], + if (widget.trailing != null) widget.trailing! + ], + ), + ), + ); + + if (widget.onTap == null) { + return child; + } + + return GestureDetector( + onTapDown: (_) => setState(() { _tapped = true; }), + onTapCancel: () => setState(() { _tapped = false; }), + onTap: () async { + await widget.onTap!(); + setState(() { _tapped = false; }); + }, + behavior: HitTestBehavior.opaque, + child: child, + ); + } +} + +/// A typical iOS trailing widget used to denote that a `CupertinoListTile` is a +/// button with an action. +/// +/// The [CupertinoListTileChevron] is meant as a convenience implementation of +/// trailing right chevron. +class CupertinoListTileChevron extends StatelessWidget { + /// Creates a typical widget used to denote that a `CupertinoListTile` is a + /// button with action. + const CupertinoListTileChevron({super.key}); + + @override + Widget build(BuildContext context) { + return Icon( + CupertinoIcons.right_chevron, + size: CupertinoTheme.of(context).textTheme.textStyle.fontSize, + color: CupertinoColors.systemGrey2.resolveFrom(context), + ); + } +} diff --git a/packages/flutter/test/cupertino/form_section_test.dart b/packages/flutter/test/cupertino/form_section_test.dart index 7aced4365bd..7a9e4f0539b 100644 --- a/packages/flutter/test/cupertino/form_section_test.dart +++ b/packages/flutter/test/cupertino/form_section_test.dart @@ -153,7 +153,7 @@ void main() { expect(find.byType(ClipRRect), findsOneWidget); }); - testWidgets('Not setting clipBehavior does not clip children section', (WidgetTester tester) async { + testWidgets('Not setting clipBehavior does not produce a RenderClipRRect object', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: Center( @@ -164,7 +164,7 @@ void main() { ), ); - final RenderClipRRect renderClip = tester.allRenderObjects.whereType().first; - expect(renderClip.clipBehavior, equals(Clip.none)); + final Iterable renderClips = tester.allRenderObjects.whereType(); + expect(renderClips, isEmpty); }); } diff --git a/packages/flutter/test/cupertino/list_section_test.dart b/packages/flutter/test/cupertino/list_section_test.dart new file mode 100644 index 00000000000..939ee82cf2b --- /dev/null +++ b/packages/flutter/test/cupertino/list_section_test.dart @@ -0,0 +1,179 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('shows header', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection( + header: const Text('Header'), + children: const [ + CupertinoListTile(title: Text('CupertinoListTile')), + ], + ), + ), + ), + ); + + expect(find.text('Header'), findsOneWidget); + }); + + testWidgets('shows footer', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection( + footer: const Text('Footer'), + children: const [ + CupertinoListTile(title: Text('CupertinoListTile')), + ], + ), + ), + ), + ); + + expect(find.text('Footer'), findsOneWidget); + }); + + testWidgets('shows long dividers in edge-to-edge section part 1', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection( + children: const [ + CupertinoListTile(title: Text('CupertinoListTile')), + ], + ), + ), + ), + ); + + // Since the children list is reconstructed with dividers in it, the column + // retrieved should have 3 items for an input [children] param with 1 child. + final Column childrenColumn = tester.widget(find.byType(Column).at(1)); + expect(childrenColumn.children.length, 3); + }); + + testWidgets('shows long dividers in edge-to-edge section part 2', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection( + children: const [ + CupertinoListTile(title: Text('CupertinoListTile')), + CupertinoListTile(title: Text('CupertinoListTile')), + ], + ), + ), + ), + ); + + // Since the children list is reconstructed with dividers in it, the column + // retrieved should have 5 items for an input [children] param with 2 + // children. Two long dividers, two rows, and one short divider. + final Column childrenColumn = tester.widget(find.byType(Column).at(1)); + expect(childrenColumn.children.length, 5); + }); + + testWidgets('does not show long dividers in insetGrouped section part 1', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection.insetGrouped( + children: const [ + CupertinoListTile(title: Text('CupertinoListTile')), + ], + ), + ), + ), + ); + + // Since the children list is reconstructed without long dividers in it, the + // column retrieved should have 1 item for an input [children] param with 1 + // child. + final Column childrenColumn = tester.widget(find.byType(Column).at(1)); + expect(childrenColumn.children.length, 1); + }); + + testWidgets('does not show long dividers in insetGrouped section part 2', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection.insetGrouped( + children: const [ + CupertinoListTile(title: Text('CupertinoListTile')), + CupertinoListTile(title: Text('CupertinoListTile')), + ], + ), + ), + ), + ); + + // Since the children list is reconstructed with short dividers in it, the + // column retrieved should have 3 items for an input [children] param with 2 + // children. Two long dividers, two rows, and one short divider. + final Column childrenColumn = tester.widget(find.byType(Column).at(1)); + expect(childrenColumn.children.length, 3); + }); + + testWidgets('sets background color for section', (WidgetTester tester) async { + const Color backgroundColor = CupertinoColors.systemBlue; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoListSection( + backgroundColor: backgroundColor, + children: const [ + CupertinoListTile(title: Text('CupertinoListTile')), + ], + ), + ), + ), + ); + + final DecoratedBox decoratedBox = tester.widget(find.byType(DecoratedBox).first); + final BoxDecoration boxDecoration = decoratedBox.decoration as BoxDecoration; + expect(boxDecoration.color, backgroundColor); + }); + + testWidgets('setting clipBehavior clips children section', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection( + clipBehavior: Clip.antiAlias, + children: const [ + CupertinoListTile(title: Text('CupertinoListTile')), + ], + ), + ), + ), + ); + + expect(find.byType(ClipRRect), findsOneWidget); + }); + + testWidgets('not setting clipBehavior does not clip children section', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: CupertinoListSection( + children: const [ + CupertinoListTile(title: Text('CupertinoListTile')), + ], + ), + ), + ), + ); + + expect(find.byType(ClipRRect), findsNothing); + }); +} diff --git a/packages/flutter/test/cupertino/list_tile_test.dart b/packages/flutter/test/cupertino/list_tile_test.dart new file mode 100644 index 00000000000..fe02b9fab0a --- /dev/null +++ b/packages/flutter/test/cupertino/list_tile_test.dart @@ -0,0 +1,482 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('shows title', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoListTile( + title: title, + ), + ), + ), + ); + + expect(tester.widget(find.byType(Text)), title); + expect(find.text('CupertinoListTile'), findsOneWidget); + }); + + testWidgets('shows subtitle', (WidgetTester tester) async { + const Widget subtitle = Text('CupertinoListTile subtitle'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoListTile( + title: Icon(CupertinoIcons.add), + subtitle: subtitle, + ), + ), + ), + ); + + expect(tester.widget(find.byType(Text)), subtitle); + expect(find.text('CupertinoListTile subtitle'), findsOneWidget); + }); + + testWidgets('shows additionalInfo', (WidgetTester tester) async { + const Widget additionalInfo = Text('Not Connected'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoListTile( + title: Icon(CupertinoIcons.add), + additionalInfo: additionalInfo, + ), + ), + ), + ); + + expect(tester.widget(find.byType(Text)), additionalInfo); + expect(find.text('Not Connected'), findsOneWidget); + }); + + testWidgets('shows trailing', (WidgetTester tester) async { + const Widget trailing = CupertinoListTileChevron(); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoListTile( + title: Icon(CupertinoIcons.add), + trailing: trailing, + ), + ), + ), + ); + + expect(tester.widget(find.byType(CupertinoListTileChevron)), trailing); + }); + + testWidgets('shows leading', (WidgetTester tester) async { + const Widget leading = Icon(CupertinoIcons.add); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: CupertinoListTile( + leading: leading, + title: Text('CupertinoListTile'), + ), + ), + ), + ); + + expect(tester.widget(find.byType(Icon)), leading); + }); + + testWidgets('sets backgroundColor', (WidgetTester tester) async { + const Color backgroundColor = CupertinoColors.systemRed; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoListSection( + children: const [ + CupertinoListTile( + title: Text('CupertinoListTile'), + backgroundColor: backgroundColor, + ), + ], + ), + ), + ), + ); + + // Container inside CupertinoListTile is the second one in row. + final Container container = tester.widgetList(find.byType(Container)).elementAt(1); + expect(container.color, backgroundColor); + }); + + testWidgets('does not change backgroundColor when tapped if onTap is not provided', (WidgetTester tester) async { + const Color backgroundColor = CupertinoColors.systemBlue; + const Color backgroundColorActivated = CupertinoColors.systemRed; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoListSection( + children: const [ + CupertinoListTile( + title: Text('CupertinoListTile'), + backgroundColor: backgroundColor, + backgroundColorActivated: backgroundColorActivated, + ), + ], + ), + ), + ), + ); + + await tester.tap(find.byType(CupertinoListTile)); + await tester.pump(); + + // Container inside CupertinoListTile is the second one in row. + final Container container = tester.widgetList(find.byType(Container)).elementAt(1); + expect(container.color, backgroundColor); + }); + + testWidgets('changes backgroundColor when tapped if onTap is provided', (WidgetTester tester) async { + const Color backgroundColor = CupertinoColors.systemBlue; + const Color backgroundColorActivated = CupertinoColors.systemRed; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoListSection( + children: [ + CupertinoListTile( + title: const Text('CupertinoListTile'), + backgroundColor: backgroundColor, + backgroundColorActivated: backgroundColorActivated, + onTap: () async { await Future.delayed(const Duration(milliseconds: 1), () {}); }, + ), + ], + ), + ), + ), + ); + + // Container inside CupertinoListTile is the second one in row. + Container container = tester.widgetList(find.byType(Container)).elementAt(1); + expect(container.color, backgroundColor); + + // Pump only one frame so the color change persists. + await tester.tap(find.byType(CupertinoListTile)); + await tester.pump(); + + // Container inside CupertinoListTile is the second one in row. + container = tester.widgetList(find.byType(Container)).elementAt(1); + expect(container.color, backgroundColorActivated); + + // Pump the rest of the frames to complete the test. + await tester.pumpAndSettle(); + }); + + testWidgets('does not contain GestureDetector if onTap is not provided', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoListSection( + children: const [ + CupertinoListTile( + title: Text('CupertinoListTile'), + ), + ], + ), + ), + ), + ); + + // Container inside CupertinoListTile is the second one in row. + expect(find.byType(GestureDetector), findsNothing); + }); + + testWidgets('contains GestureDetector if onTap is provided', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoListSection( + children: [ + CupertinoListTile( + title: const Text('CupertinoListTile'), + onTap: () async {}, + ), + ], + ), + ), + ), + ); + + // Container inside CupertinoListTile is the second one in row. + expect(find.byType(GestureDetector), findsOneWidget); + }); + + testWidgets('resets the background color when navigated back', (WidgetTester tester) async { + const Color backgroundColor = CupertinoColors.systemBlue; + const Color backgroundColorActivated = CupertinoColors.systemRed; + + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + final Widget secondPage = Center( + child: CupertinoButton( + child: const Text('Go back'), + onPressed: () => Navigator.of(context).pop(), + ), + ); + return Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child:CupertinoListTile( + title: const Text('CupertinoListTile'), + backgroundColor: backgroundColor, + backgroundColorActivated: backgroundColorActivated, + onTap: () => Navigator.of(context).push(CupertinoPageRoute( + builder: (BuildContext context) => secondPage, + )), + ), + ), + ), + ); + }, + ), + ), + ); + + // Navigate to second page. + await tester.tap(find.byType(CupertinoListTile)); + await tester.pumpAndSettle(); + + // Go back to first page. + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + + // Container inside CupertinoListTile is the second one in row. + final Container container = tester.widget(find.byType(Container)); + expect(container.color, backgroundColor); + }); + + group('alignment of widgets for left-to-right', () { + testWidgets('leading is on the left of title', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile'); + const Widget leading = Icon(CupertinoIcons.add); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoListTile( + title: title, + leading: leading, + ), + ), + ), + ), + ); + + final Offset foundTitle = tester.getTopLeft(find.byType(Text)); + final Offset foundLeading = tester.getTopRight(find.byType(Icon)); + + expect(foundTitle.dx > foundLeading.dx, true); + }); + + testWidgets('subtitle is placed below title and aligned on left', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile title'); + const Widget subtitle = Text('CupertinoListTile subtitle'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoListTile( + title: title, + subtitle: subtitle, + ), + ), + ), + ), + ); + + final Offset foundTitle = tester.getBottomLeft(find.text('CupertinoListTile title')); + final Offset foundSubtitle = tester.getTopLeft(find.text('CupertinoListTile subtitle')); + + expect(foundTitle.dx, equals(foundSubtitle.dx)); + expect(foundTitle.dy < foundSubtitle.dy, isTrue); + }); + + testWidgets('additionalInfo is on the right of title', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile'); + const Widget additionalInfo = Text('Not Connected'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoListTile( + title: title, + additionalInfo: additionalInfo, + ), + ), + ), + ), + ); + + final Offset foundTitle = tester.getTopRight(find.text('CupertinoListTile')); + final Offset foundInfo = tester.getTopLeft(find.text('Not Connected')); + + expect(foundTitle.dx < foundInfo.dx, isTrue); + }); + + testWidgets('trailing is on the right of additionalInfo', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile'); + const Widget additionalInfo = Text('Not Connected'); + const Widget trailing = CupertinoListTileChevron(); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: CupertinoListTile( + title: title, + additionalInfo: additionalInfo, + trailing: trailing, + ), + ), + ), + ), + ); + + final Offset foundInfo = tester.getTopRight(find.text('Not Connected')); + final Offset foundTrailing = tester.getTopLeft(find.byType(CupertinoListTileChevron)); + + expect(foundInfo.dx < foundTrailing.dx, isTrue); + }); + }); + + group('alignment of widgets for right-to-left', () { + testWidgets('leading is on the right of title', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile'); + const Widget leading = Icon(CupertinoIcons.add); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.rtl, + child: CupertinoListTile( + title: title, + leading: leading, + ), + ), + ), + ), + ); + + final Offset foundTitle = tester.getTopRight(find.byType(Text)); + final Offset foundLeading = tester.getTopLeft(find.byType(Icon)); + + expect(foundTitle.dx < foundLeading.dx, true); + }); + + testWidgets('subtitle is placed below title and aligned on right', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile title'); + const Widget subtitle = Text('CupertinoListTile subtitle'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.rtl, + child: CupertinoListTile( + title: title, + subtitle: subtitle, + ), + ), + ), + ), + ); + + final Offset foundTitle = tester.getBottomRight(find.text('CupertinoListTile title')); + final Offset foundSubtitle = tester.getTopRight(find.text('CupertinoListTile subtitle')); + + expect(foundTitle.dx, equals(foundSubtitle.dx)); + expect(foundTitle.dy < foundSubtitle.dy, isTrue); + }); + + testWidgets('additionalInfo is on the left of title', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile'); + const Widget additionalInfo = Text('Not Connected'); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.rtl, + child: CupertinoListTile( + title: title, + additionalInfo: additionalInfo, + ), + ), + ), + ), + ); + + final Offset foundTitle = tester.getTopLeft(find.text('CupertinoListTile')); + final Offset foundInfo = tester.getTopRight(find.text('Not Connected')); + + expect(foundTitle.dx > foundInfo.dx, isTrue); + }); + + testWidgets('trailing is on the left of additionalInfo', (WidgetTester tester) async { + const Widget title = Text('CupertinoListTile'); + const Widget additionalInfo = Text('Not Connected'); + const Widget trailing = CupertinoListTileChevron(); + + await tester.pumpWidget( + const CupertinoApp( + home: Center( + child: Directionality( + textDirection: TextDirection.rtl, + child: CupertinoListTile( + title: title, + additionalInfo: additionalInfo, + trailing: trailing, + ), + ), + ), + ), + ); + + final Offset foundInfo = tester.getTopLeft(find.text('Not Connected')); + final Offset foundTrailing = tester.getTopRight(find.byType(CupertinoListTileChevron)); + + expect(foundInfo.dx > foundTrailing.dx, isTrue); + }); + }); +}