diff --git a/examples/api/lib/widgets/safe_area/safe_area.0.dart b/examples/api/lib/widgets/safe_area/safe_area.0.dart new file mode 100644 index 0000000000..3c3f877937 --- /dev/null +++ b/examples/api/lib/widgets/safe_area/safe_area.0.dart @@ -0,0 +1,288 @@ +// 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/material.dart'; + +/// Flutter code sample for [SafeArea]. +/// +/// The app is wrapped with [Insets] (defined below) +/// to simulate a mobile device with a notched screen. + +void main() => runApp(const Insets()); + +class SafeAreaExampleApp extends StatelessWidget { + const SafeAreaExampleApp({super.key}); + + static const Color spring = Color(0xFF00FF80); + static final ColorScheme colors = ColorScheme.fromSeed(seedColor: spring); + static final ThemeData theme = ThemeData( + colorScheme: colors, + sliderTheme: SliderThemeData( + trackHeight: 8, + activeTrackColor: colors.primary.withValues(alpha: 0.5), + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 12), + ), + scaffoldBackgroundColor: const Color(0xFFD0FFE8), + listTileTheme: const ListTileThemeData( + tileColor: Colors.white70, + contentPadding: EdgeInsets.all(6.0), + ), + appBarTheme: AppBarTheme( + backgroundColor: colors.secondary, + foregroundColor: colors.onSecondary, + ), + ); + + static final AppBar appBar = AppBar(title: const Text('SafeArea Demo')); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: theme, + debugShowCheckedModeBanner: false, + home: Builder( + builder: (BuildContext context) => Scaffold( + appBar: Toggle.appBar.of(context) ? appBar : null, + body: const DefaultTextStyle( + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black, + ), + child: Center( + child: SafeAreaExample(), + ), + ), + ), + ), + ); + } +} + +class SafeAreaExample extends StatelessWidget { + const SafeAreaExample({super.key}); + + static final Widget controls = Column( + children: [ + const SizedBox(height: 14), + Builder( + builder: (BuildContext context) => Text( + Toggle.safeArea.of(context) ? 'safe area!' : 'no safe area', + style: const TextStyle(fontSize: 24), + ), + ), + const Spacer(flex: 2), + for (final Value data in Value.allValues) ...data.controls, + ], + ); + + @override + Widget build(BuildContext context) { + final bool hasSafeArea = Toggle.safeArea.of(context); + + return SafeArea( + top: hasSafeArea, + bottom: hasSafeArea, + left: hasSafeArea, + right: hasSafeArea, + child: controls, + ); + } +} + +sealed class Value implements Enum { + Object _getValue(covariant Model model); + + List get controls; + + static const List allValues = [...Inset.values, ...Toggle.values]; +} + +enum Inset implements Value { + top('top notch'), + sides('side padding'), + bottom('bottom indicator'); + + const Inset(this.label); + + final String label; + + @override + double _getValue(_InsetModel model) => switch (this) { + top => model.insets.top, + sides => model.insets.left, + bottom => model.insets.bottom, + }; + + double of(BuildContext context) => _getValue(Model.of<_InsetModel>(context, this)); + + @override + List get controls => [ + Text(label), + Builder( + builder: (BuildContext context) => Slider( + max: 50, + value: of(context), + onChanged: (double newValue) { + InsetsState.instance.changeInset(this, newValue); + }, + ), + ), + const Spacer(), + ]; +} + +enum Toggle implements Value { + appBar('Build an AppBar?'), + safeArea("Wrap Scaffold's body with SafeArea?"); + + const Toggle(this.label); + + final String label; + + @override + bool _getValue(_ToggleModel model) => switch (this) { + appBar => model.buildAppBar, + safeArea => model.buildSafeArea, + }; + + bool of(BuildContext context) => _getValue(Model.of<_ToggleModel>(context, this)); + + @override + List get controls => [ + Builder( + builder: (BuildContext context) => SwitchListTile( + title: Text(label), + value: of(context), + onChanged: (bool value) { + InsetsState.instance.toggle(this, value); + }, + ), + ), + ]; +} + +abstract class Model extends InheritedModel { + const Model({super.key, required super.child}); + + static M of>(BuildContext context, Value value) { + return context.dependOnInheritedWidgetOfExactType(aspect: value)!; + } + + @override + bool updateShouldNotify(Model oldWidget) => true; + + @override + bool updateShouldNotifyDependent(Model oldWidget, Set dependencies) { + return dependencies.any((E data) => data._getValue(this) != data._getValue(oldWidget)); + } +} + +class _InsetModel extends Model { + const _InsetModel({required this.insets, required super.child}); + + final EdgeInsets insets; +} + +class _ToggleModel extends Model { + _ToggleModel({required Set togglers, required super.child}) + : buildAppBar = togglers.contains(Toggle.appBar), + buildSafeArea = togglers.contains(Toggle.safeArea); + + final bool buildAppBar; + final bool buildSafeArea; +} + +class Insets extends UniqueWidget { + const Insets() : super(key: const GlobalObjectKey('insets')); + + @override + InsetsState createState() => InsetsState(); +} + +class InsetsState extends State { + static InsetsState get instance => const Insets().currentState!; + + EdgeInsets insets = const EdgeInsets.fromLTRB(8, 25, 8, 12); + void changeInset(Inset inset, double value) { + setState(() { + insets = switch (inset) { + Inset.top => insets.copyWith(top: value), + Inset.sides => insets.copyWith(left: value, right: value), + Inset.bottom => insets.copyWith(bottom: value), + }; + }); + } + + final Set _togglers = {}; + void toggle(Toggle toggler, bool value) { + setState(() { + value ? _togglers.add(toggler) : _togglers.remove(toggler); + }); + } + + @override + Widget build(BuildContext context) { + final Widget topNotch = ClipRRect( + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(insets.top), + ), + child: SizedBox( + height: insets.top, + child: const FractionallySizedBox( + widthFactor: 1 / 2, + child: ColoredBox(color: Colors.black), + ), + ), + ); + final Widget bottomIndicator = SizedBox( + width: double.infinity, + height: insets.bottom, + child: const FractionallySizedBox( + heightFactor: 0.5, + widthFactor: 0.5, + child: PhysicalShape( + clipper: ShapeBorderClipper(shape: StadiumBorder()), + color: Color(0xC0000000), + child: SizedBox.expand(), + ), + ), + ); + final Widget sideBar = SizedBox( + width: insets.left, + height: double.infinity, + child: const IgnorePointer(child: ColoredBox(color: Colors.black12)), + ); + + final Widget app = _ToggleModel( + togglers: _togglers, + child: Builder( + builder: (BuildContext context) => MediaQuery( + data: MediaQuery.of(context).copyWith( + viewInsets: EdgeInsets.only(top: insets.top), + viewPadding: insets, + padding: insets, + ), + child: const SafeAreaExampleApp(), + ), + ), + ); + + return _InsetModel( + insets: insets, + child: Directionality( + textDirection: TextDirection.ltr, + child: Stack( + children: [ + app, + Align(alignment: Alignment.topCenter, child: topNotch), + Align(alignment: Alignment.bottomCenter, child: bottomIndicator), + Align(alignment: Alignment.centerLeft, child: sideBar), + Align(alignment: Alignment.centerRight, child: sideBar), + ], + ), + ), + ); + } +} diff --git a/examples/api/test/widgets/safe_area/safe_area.0_test.dart b/examples/api/test/widgets/safe_area/safe_area.0_test.dart new file mode 100644 index 0000000000..a0bf4c3d22 --- /dev/null +++ b/examples/api/test/widgets/safe_area/safe_area.0_test.dart @@ -0,0 +1,84 @@ +// 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/material.dart'; +import 'package:flutter_api_samples/widgets/safe_area/safe_area.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +example.InsetsState get insetsState => example.InsetsState.instance; + +void main() { + testWidgets('SafeArea widget count', (WidgetTester tester) async { + await tester.pumpWidget(const example.Insets()); + + // 1 SafeArea and 2 ListTile widgets. + expect(find.byType(SafeArea), findsNWidgets(3)); + + insetsState.toggle(example.Toggle.appBar, true); + await tester.pump(); + expect(find.byType(SafeArea), findsNWidgets(4)); + }); + + testWidgets('ListTile removes side padding from its content', (WidgetTester tester) async { + await tester.pumpWidget(const example.Insets()); + + late BuildContext context; + late EdgeInsets padding; + + final Finder findBody = find.byType(example.SafeAreaExample); + final Finder findListTile = find.text(example.Toggle.appBar.label).first; + + context = tester.element(findBody); + padding = MediaQuery.paddingOf(context); + expect(padding.top, greaterThan(0)); + expect(padding.bottom, greaterThan(0)); + expect(padding.left, greaterThan(0)); + expect(padding.right, greaterThan(0)); + + context = tester.element(findListTile); + padding = MediaQuery.paddingOf(context); + expect(padding.top, greaterThan(0)); + expect(padding.bottom, greaterThan(0)); + expect(padding.left, 0); + expect(padding.right, 0); + }); + + testWidgets('AppBar removes top padding of Scaffold body', (WidgetTester tester) async { + await tester.pumpWidget(const example.Insets()); + final BuildContext context = tester.element(find.text('no safe area')); + + // Double-check that side & bottom padding are unchanged. + void verifySidesAndBottom() { + final EdgeInsets padding = MediaQuery.paddingOf(context); + expect(padding.left, example.Inset.sides.of(context)); + expect(padding.right, example.Inset.sides.of(context)); + expect(padding.bottom, example.Inset.bottom.of(context)); + } + + final double topInset = example.Inset.top.of(context); + expect(topInset, greaterThan(0)); + expect(MediaQuery.paddingOf(context).top, topInset); + verifySidesAndBottom(); + + insetsState.toggle(example.Toggle.appBar, true); + await tester.pump(); + + expect(example.Inset.top.of(context), greaterThan(0)); + expect(MediaQuery.paddingOf(context).top, 0); + verifySidesAndBottom(); + }); + + testWidgets('SafeArea removes all padding', (WidgetTester tester) async { + await tester.pumpWidget(const example.Insets()); + + BuildContext context = tester.element(find.text('no safe area')); + expect(MediaQuery.paddingOf(context), insetsState.insets); + + insetsState.toggle(example.Toggle.safeArea, true); + await tester.pump(); + + context = tester.element(find.text('safe area!')); + expect(MediaQuery.paddingOf(context), EdgeInsets.zero); + }); +} diff --git a/packages/flutter/lib/src/widgets/safe_area.dart b/packages/flutter/lib/src/widgets/safe_area.dart index 023e8fe1f1..e84164c0f4 100644 --- a/packages/flutter/lib/src/widgets/safe_area.dart +++ b/packages/flutter/lib/src/widgets/safe_area.dart @@ -16,15 +16,16 @@ import 'media_query.dart'; /// /// {@youtube 560 315 https://www.youtube.com/watch?v=lkF0TQJO0bA} /// -/// For example, this will indent the child by enough to avoid the status bar at -/// the top of the screen. -/// -/// It will also indent the child by the amount necessary to avoid The Notch on -/// the iPhone X, or other similar creative physical features of the display. -/// /// When a [minimum] padding is specified, the greater of the minimum padding /// or the safe area padding will be applied. /// +/// {@tool dartpad} +/// This example shows how `SafeArea` can apply padding within a mobile device's +/// screen to make the relevant content completely visible. +/// +/// ** See code in examples/api/lib/widgets/safe_area/safe_area.0.dart ** +/// {@end-tool} +/// /// {@tool snippet} /// /// This example creates a blue box containing text that is sufficiently padded