Unverified Commit 4a7e30f8 authored by Andrei Diaconu's avatar Andrei Diaconu Committed by GitHub

Add DisplayFeatureSubScreen widget (#92907)

parent ba4d63a4
// 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:math' as math;
import 'dart:ui' show DisplayFeature;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'basic.dart';
import 'debug.dart';
import 'framework.dart';
import 'media_query.dart';
/// Positions [child] such that it avoids overlapping any [DisplayFeature] that
/// splits the screen into sub-screens.
///
/// A [DisplayFeature] splits the screen into sub-screens when both these
/// conditions are met:
///
/// * it obstructs the screen, meaning the area it occupies is not 0. Display
/// features of type [DisplayFeatureType.fold] can have height 0 or width 0
/// and not be obstructing the screen.
/// * it is at least as tall as the screen, producing a left and right
/// sub-screen or it is at least as wide as the screen, producing a top and
/// bottom sub-screen
///
/// After determining the sub-screens, the closest one to [anchorPoint] is used
/// to render the [child].
///
/// If no [anchorPoint] is provided, then [Directionality] is used:
///
/// * for [TextDirection.ltr], [anchorPoint] is `Offset.zero`, which will
/// cause the [child] to appear in the top-left sub-screen.
/// * for [TextDirection.rtl], [anchorPoint] is `Offset(double.maxFinite, 0)`,
/// which will cause the [child] to appear in the top-right sub-screen.
///
/// If no [anchorPoint] is provided, and there is no [Directionality] ancestor
/// widget in the tree, then the widget asserts during build in debug mode.
///
/// Similarly to [SafeArea], this widget assumes there is no added padding
/// between it and the first [MediaQuery] ancestor. The [child] is wrapped in a
/// new [MediaQuery] instance containing the [DisplayFeature]s that exist in the
/// selected sub-screen, with coordinates relative to the sub-screen. Padding is
/// also adjusted to zero out any sides that were avoided by this widget.
///
/// See also:
///
/// * [showDialog], which is a way to display a [DialogRoute].
/// * [showCupertinoDialog], which displays an iOS-style dialog.
class DisplayFeatureSubScreen extends StatelessWidget {
/// Creates a widget that positions its child so that it avoids display
/// features.
const DisplayFeatureSubScreen({
Key? key,
this.anchorPoint,
required this.child,
}) : super(key: key);
/// The anchor point used to pick the closest sub-screen.
///
/// If the anchor point sits inside one of these sub-screens, then that
/// sub-screen is picked. If not, then the sub-screen with the closest edge to
/// the point is used.
///
/// [Offset.zero] is the top-left corner of the available screen space. For a
/// vertically split dual-screen device, this is the top-left corner of the
/// left screen.
///
/// When this is null, [Directionality] is used:
///
/// * for [TextDirection.ltr], [anchorPoint] is [Offset.zero], which will
/// cause the top-left sub-screen to be picked.
/// * for [TextDirection.rtl], [anchorPoint] is
/// `Offset(double.maxFinite, 0)`, which will cause the top-right
/// sub-screen to be picked.
final Offset? anchorPoint;
/// The widget below this widget in the tree.
///
/// The padding on the [MediaQuery] for the [child] will be suitably adjusted
/// to zero out any sides that were avoided by this widget. The [MediaQuery]
/// for the [child] will no longer contain any display features that split the
/// screen into sub-screens.
///
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget child;
@override
Widget build(BuildContext context) {
assert(anchorPoint != null || debugCheckHasDirectionality(
context,
why: 'to determine which sub-screen DisplayFeatureSubScreen uses',
alternative: "Alternatively, consider specifying the 'anchorPoint' argument on the DisplayFeatureSubScreen.",
));
final MediaQueryData mediaQuery = MediaQuery.of(context);
final Size parentSize = mediaQuery.size;
final Rect wantedBounds = Offset.zero & parentSize;
final Offset resolvedAnchorPoint = _capOffset(anchorPoint ?? _fallbackAnchorPoint(context), parentSize);
final Iterable<Rect> subScreens = _subScreensInBounds(wantedBounds, _avoidBounds(mediaQuery));
final Rect closestSubScreen = _closestToAnchorPoint(subScreens, resolvedAnchorPoint);
return Padding(
padding: EdgeInsets.only(
left: closestSubScreen.left,
top: closestSubScreen.top,
right: parentSize.width - closestSubScreen.right,
bottom: parentSize.height - closestSubScreen.bottom,
),
child: MediaQuery(
data: mediaQuery.removeDisplayFeatures(closestSubScreen),
child: child,
),
);
}
static Offset _fallbackAnchorPoint(BuildContext context) {
final TextDirection textDirection = Directionality.of(context);
switch (textDirection) {
case TextDirection.rtl:
return const Offset(double.maxFinite, 0);
case TextDirection.ltr:
return Offset.zero;
}
}
static Iterable<Rect> _avoidBounds(MediaQueryData mediaQuery) {
return mediaQuery.displayFeatures.map((DisplayFeature d) => d.bounds)
.where((Rect r) => r.shortestSide > 0);
}
/// Returns the closest sub-screen to the [anchorPoint].
static Rect _closestToAnchorPoint(Iterable<Rect> subScreens, Offset anchorPoint) {
Rect closestScreen = subScreens.first;
double closestDistance = _distanceFromPointToRect(anchorPoint, closestScreen);
for (final Rect screen in subScreens) {
final double subScreenDistance = _distanceFromPointToRect(anchorPoint, screen);
if (subScreenDistance < closestDistance) {
closestScreen = screen;
closestDistance = subScreenDistance;
}
}
return closestScreen;
}
static double _distanceFromPointToRect(Offset point, Rect rect) {
// Cases for point position relative to rect:
// 1 2 3
// 4 [R] 5
// 6 7 8
if (point.dx < rect.left) {
if (point.dy < rect.top) {
// Case 1
return (point - rect.topLeft).distance;
} else if (point.dy > rect.bottom) {
// Case 6
return (point - rect.bottomLeft).distance;
} else {
// Case 4
return rect.left - point.dx;
}
} else if (point.dx > rect.right) {
if (point.dy < rect.top) {
// Case 3
return (point - rect.topRight).distance;
} else if (point.dy > rect.bottom) {
// Case 8
return (point - rect.bottomRight).distance;
} else {
// Case 5
return point.dx - rect.right;
}
} else {
if (point.dy < rect.top) {
// Case 2
return rect.top - point.dy;
} else if (point.dy > rect.bottom) {
// Case 7
return point.dy - rect.bottom;
} else {
// Case R
return 0;
}
}
}
/// Returns sub-screens resulted by dividing [wantedBounds] along items of
/// [avoidBounds] that are at least as high or as wide.
static Iterable<Rect> _subScreensInBounds(Rect wantedBounds, Iterable<Rect> avoidBounds) {
Iterable<Rect> subScreens = <Rect>[wantedBounds];
for (final Rect bounds in avoidBounds) {
final List<Rect> newSubScreens = <Rect>[];
for (final Rect screen in subScreens) {
if (screen.top >= bounds.top && screen.bottom <= bounds.bottom) {
// Display feature splits the screen vertically
if (screen.left < bounds.left) {
// There is a smaller sub-screen, left of the display feature
newSubScreens.add(Rect.fromLTWH(
screen.left,
screen.top,
bounds.left - screen.left,
screen.height,
));
}
if (screen.right > bounds.right) {
// There is a smaller sub-screen, right of the display feature
newSubScreens.add(Rect.fromLTWH(
bounds.right,
screen.top,
screen.right - bounds.right,
screen.height,
));
}
} else if (screen.left >= bounds.left && screen.right <= bounds.right) {
// Display feature splits the sub-screen horizontally
if (screen.top < bounds.top) {
// There is a smaller sub-screen, above the display feature
newSubScreens.add(Rect.fromLTWH(
screen.left,
screen.top,
screen.width,
bounds.top - screen.top,
));
}
if (screen.bottom > bounds.bottom) {
// There is a smaller sub-screen, below the display feature
newSubScreens.add(Rect.fromLTWH(
screen.left,
bounds.bottom,
screen.width,
screen.bottom - bounds.bottom,
));
}
} else {
newSubScreens.add(screen);
}
}
subScreens = newSubScreens;
}
return subScreens;
}
static Offset _capOffset(Offset offset, Size maximum) {
if (offset.dx >= 0 && offset.dx <= maximum.width
&& offset.dy >=0 && offset.dy <= maximum.height) {
return offset;
} else {
return Offset(
math.min(math.max(0, offset.dx), maximum.width),
math.min(math.max(0, offset.dy), maximum.height),
);
}
}
}
......@@ -567,6 +567,55 @@ class MediaQueryData {
);
}
/// Creates a copy of this media query data by removing [displayFeatures] that
/// are completely outside the given sub-screen and adjusting the [padding],
/// [viewInsets] and [viewPadding] to be zero on the sides that are not
/// included in the sub-screen.
///
/// Returns unmodified [MediaQueryData] if the sub-screen coincides with the
/// available screen space.
///
/// Asserts in debug mode, if the given sub-screen is outside the available
/// screen space.
///
/// See also:
///
/// * [DisplayFeatureSubScreen], which removes the display features that
/// split the screen, from the [MediaQuery] and adds a [Padding] widget to
/// position the child to match the selected sub-screen.
MediaQueryData removeDisplayFeatures(Rect subScreen) {
assert(subScreen.left >= 0.0 && subScreen.top >= 0.0 &&
subScreen.right <= size.width && subScreen.bottom <= size.height,
"'subScreen' argument cannot be outside the bounds of the screen");
if (subScreen.size == size && subScreen.topLeft == Offset.zero)
return this;
final double rightInset = size.width - subScreen.right;
final double bottomInset = size.height - subScreen.bottom;
return copyWith(
padding: EdgeInsets.only(
left: math.max(0.0, padding.left - subScreen.left),
top: math.max(0.0, padding.top - subScreen.top),
right: math.max(0.0, padding.right - rightInset),
bottom: math.max(0.0, padding.bottom - bottomInset),
),
viewPadding: EdgeInsets.only(
left: math.max(0.0, viewPadding.left - subScreen.left),
top: math.max(0.0, viewPadding.top - subScreen.top),
right: math.max(0.0, viewPadding.right - rightInset),
bottom: math.max(0.0, viewPadding.bottom - bottomInset),
),
viewInsets: EdgeInsets.only(
left: math.max(0.0, viewInsets.left - subScreen.left),
top: math.max(0.0, viewInsets.top - subScreen.top),
right: math.max(0.0, viewInsets.right - rightInset),
bottom: math.max(0.0, viewInsets.bottom - bottomInset),
),
displayFeatures: displayFeatures.where(
(ui.DisplayFeature displayFeature) => subScreen.overlaps(displayFeature.bounds)
).toList(),
);
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType)
......
......@@ -36,6 +36,7 @@ export 'src/widgets/debug.dart';
export 'src/widgets/default_text_editing_shortcuts.dart';
export 'src/widgets/desktop_text_selection_toolbar_layout_delegate.dart';
export 'src/widgets/dismissible.dart';
export 'src/widgets/display_feature_sub_screen.dart';
export 'src/widgets/disposable_build_context.dart';
export 'src/widgets/drag_target.dart';
export 'src/widgets/draggable_scrollable_sheet.dart';
......
// 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:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('DisplayFeatureSubScreen', () {
testWidgets('without Directionality or anchor', (WidgetTester tester) async {
const Key childKey = Key('childKey');
final MediaQueryData mediaQuery = MediaQueryData.fromWindow(WidgetsBinding.instance!.window).copyWith(
displayFeatures: <DisplayFeature>[
const DisplayFeature(
bounds: Rect.fromLTRB(390, 0, 410, 600),
type: DisplayFeatureType.hinge,
state: DisplayFeatureState.unknown,
),
]
);
await tester.pumpWidget(
MediaQuery(
data: mediaQuery,
child: const DisplayFeatureSubScreen(
child: SizedBox(
key: childKey,
width: double.infinity,
height: double.infinity,
),
),
),
);
// With no Directionality or anchorpoint, the widget throws
final String message = tester.takeException().toString();
expect(message, contains('Directionality'));
});
testWidgets('with anchorPoint', (WidgetTester tester) async {
const Key childKey = Key('childKey');
final MediaQueryData mediaQuery = MediaQueryData.fromWindow(WidgetsBinding.instance!.window).copyWith(
displayFeatures: <DisplayFeature>[
const DisplayFeature(
bounds: Rect.fromLTRB(390, 0, 410, 600),
type: DisplayFeatureType.hinge,
state: DisplayFeatureState.unknown,
),
]
);
await tester.pumpWidget(
MediaQuery(
data: mediaQuery,
child: const DisplayFeatureSubScreen(
anchorPoint: Offset(600, 300),
child: SizedBox(
key: childKey,
width: double.infinity,
height: double.infinity,
),
),
),
);
// anchorPoint is in the middle of the right screen
final RenderBox renderBox = tester.renderObject(find.byKey(childKey));
expect(renderBox.size.width, equals(390.0));
expect(renderBox.size.height, equals(600.0));
expect(renderBox.localToGlobal(Offset.zero), equals(const Offset(410,0)));
});
testWidgets('with infinity anchorpoint', (WidgetTester tester) async {
const Key childKey = Key('childKey');
final MediaQueryData mediaQuery = MediaQueryData.fromWindow(WidgetsBinding.instance!.window).copyWith(
displayFeatures: <DisplayFeature>[
const DisplayFeature(
bounds: Rect.fromLTRB(390, 0, 410, 600),
type: DisplayFeatureType.hinge,
state: DisplayFeatureState.unknown,
),
]
);
await tester.pumpWidget(
MediaQuery(
data: mediaQuery,
child: const DisplayFeatureSubScreen(
anchorPoint: Offset.infinite,
child: SizedBox(
key: childKey,
width: double.infinity,
height: double.infinity,
),
),
),
);
// anchorPoint is infinite, so the bottom-most & right-most screen is chosen
final RenderBox renderBox = tester.renderObject(find.byKey(childKey));
expect(renderBox.size.width, equals(390.0));
expect(renderBox.size.height, equals(600.0));
expect(renderBox.localToGlobal(Offset.zero), equals(const Offset(410,0)));
});
testWidgets('with horizontal hinge and anchorPoint', (WidgetTester tester) async {
const Key childKey = Key('childKey');
final MediaQueryData mediaQuery = MediaQueryData.fromWindow(WidgetsBinding.instance!.window).copyWith(
displayFeatures: <DisplayFeature>[
const DisplayFeature(
bounds: Rect.fromLTRB(0, 290, 800, 310),
type: DisplayFeatureType.hinge,
state: DisplayFeatureState.unknown,
),
]
);
await tester.pumpWidget(
MediaQuery(
data: mediaQuery,
child: const DisplayFeatureSubScreen(
anchorPoint: Offset(1000, 1000),
child: SizedBox(
key: childKey,
width: double.infinity,
height: double.infinity,
),
),
),
);
final RenderBox renderBox = tester.renderObject(find.byKey(childKey));
expect(renderBox.size.width, equals(800.0));
expect(renderBox.size.height, equals(290.0));
expect(renderBox.localToGlobal(Offset.zero), equals(const Offset(0,310)));
});
testWidgets('with multiple display features and anchorPoint', (WidgetTester tester) async {
const Key childKey = Key('childKey');
final MediaQueryData mediaQuery = MediaQueryData.fromWindow(WidgetsBinding.instance!.window).copyWith(
displayFeatures: <DisplayFeature>[
const DisplayFeature(
bounds: Rect.fromLTRB(0, 290, 800, 310),
type: DisplayFeatureType.hinge,
state: DisplayFeatureState.unknown,
),
const DisplayFeature(
bounds: Rect.fromLTRB(390, 0, 410, 600),
type: DisplayFeatureType.hinge,
state: DisplayFeatureState.unknown,
),
]
);
await tester.pumpWidget(
MediaQuery(
data: mediaQuery,
child: const DisplayFeatureSubScreen(
anchorPoint: Offset(1000, 1000),
child: SizedBox(
key: childKey,
width: double.infinity,
height: double.infinity,
),
),
),
);
final RenderBox renderBox = tester.renderObject(find.byKey(childKey));
expect(renderBox.size.width, equals(390.0));
expect(renderBox.size.height, equals(290.0));
expect(renderBox.localToGlobal(Offset.zero), equals(const Offset(410,310)));
});
testWidgets('with non-splitting display features and anchorPoint', (WidgetTester tester) async {
const Key childKey = Key('childKey');
final MediaQueryData mediaQuery = MediaQueryData.fromWindow(WidgetsBinding.instance!.window).copyWith(
displayFeatures: <DisplayFeature>[
// Top notch
const DisplayFeature(
bounds: Rect.fromLTRB(100, 0, 700, 100),
type: DisplayFeatureType.cutout,
state: DisplayFeatureState.unknown,
),
// Bottom notch
const DisplayFeature(
bounds: Rect.fromLTRB(100, 500, 700, 600),
type: DisplayFeatureType.cutout,
state: DisplayFeatureState.unknown,
),
]
);
await tester.pumpWidget(
MediaQuery(
data: mediaQuery,
child: const Directionality(
textDirection: TextDirection.ltr,
child: DisplayFeatureSubScreen(
child: SizedBox(
key: childKey,
width: double.infinity,
height: double.infinity,
),
),
),
),
);
// The display features provided are not wide enough to produce sub-screens
final RenderBox renderBox = tester.renderObject(find.byKey(childKey));
expect(renderBox.size.width, equals(800.0));
expect(renderBox.size.height, equals(600.0));
expect(renderBox.localToGlobal(Offset.zero), equals(Offset.zero));
});
});
}
......@@ -760,4 +760,158 @@ void main() {
expect(settingsA, equals(settingsC));
expect(settingsA, isNot(settingsB));
});
testWidgets('MediaQuery.removeDisplayFeatures removes specified display features and padding', (WidgetTester tester) async {
const Size size = Size(82.0, 40.0);
const double devicePixelRatio = 2.0;
const double textScaleFactor = 1.2;
const EdgeInsets padding = EdgeInsets.only(top: 1.0, right: 2.0, left: 3.0, bottom: 4.0);
const EdgeInsets viewPadding = EdgeInsets.only(top: 6.0, right: 8.0, left: 10.0, bottom: 12.0);
const EdgeInsets viewInsets = EdgeInsets.only(top: 5.0, right: 6.0, left: 7.0, bottom: 8.0);
const List<DisplayFeature> displayFeatures = <DisplayFeature>[
DisplayFeature(
bounds: Rect.fromLTRB(40, 0, 42, 40),
type: DisplayFeatureType.hinge,
state: DisplayFeatureState.postureFlat,
),
DisplayFeature(
bounds: Rect.fromLTRB(70, 10, 74, 14),
type: DisplayFeatureType.cutout,
state: DisplayFeatureState.unknown,
),
];
// A section of the screen that intersects no display feature or padding area
const Rect subScreen = Rect.fromLTRB(20, 10, 40, 20);
late MediaQueryData subScreenMediaQuery;
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
size: size,
devicePixelRatio: devicePixelRatio,
textScaleFactor: textScaleFactor,
padding: padding,
viewPadding: viewPadding,
viewInsets: viewInsets,
alwaysUse24HourFormat: true,
accessibleNavigation: true,
invertColors: true,
disableAnimations: true,
boldText: true,
highContrast: true,
displayFeatures: displayFeatures,
),
child: Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).removeDisplayFeatures(subScreen),
child: Builder(
builder: (BuildContext context) {
subScreenMediaQuery = MediaQuery.of(context);
return Container();
},
),
);
},
),
),
);
expect(subScreenMediaQuery.size, size);
expect(subScreenMediaQuery.devicePixelRatio, devicePixelRatio);
expect(subScreenMediaQuery.textScaleFactor, textScaleFactor);
expect(subScreenMediaQuery.padding, EdgeInsets.zero);
expect(subScreenMediaQuery.viewPadding, EdgeInsets.zero);
expect(subScreenMediaQuery.viewInsets, EdgeInsets.zero);
expect(subScreenMediaQuery.alwaysUse24HourFormat, true);
expect(subScreenMediaQuery.accessibleNavigation, true);
expect(subScreenMediaQuery.invertColors, true);
expect(subScreenMediaQuery.disableAnimations, true);
expect(subScreenMediaQuery.boldText, true);
expect(subScreenMediaQuery.highContrast, true);
expect(subScreenMediaQuery.displayFeatures, isEmpty);
});
testWidgets('MediaQuery.removePadding only removes specified display features and padding', (WidgetTester tester) async {
const Size size = Size(82.0, 40.0);
const double devicePixelRatio = 2.0;
const double textScaleFactor = 1.2;
const EdgeInsets padding = EdgeInsets.only(top: 1.0, right: 2.0, left: 3.0, bottom: 4.0);
const EdgeInsets viewPadding = EdgeInsets.only(top: 6.0, right: 8.0, left: 46.0, bottom: 12.0);
const EdgeInsets viewInsets = EdgeInsets.only(top: 5.0, right: 6.0, left: 7.0, bottom: 8.0);
const DisplayFeature cutoutDisplayFeature = DisplayFeature(
bounds: Rect.fromLTRB(70, 10, 74, 14),
type: DisplayFeatureType.cutout,
state: DisplayFeatureState.unknown,
);
const List<DisplayFeature> displayFeatures = <DisplayFeature>[
DisplayFeature(
bounds: Rect.fromLTRB(40, 0, 42, 40),
type: DisplayFeatureType.hinge,
state: DisplayFeatureState.postureFlat,
),
cutoutDisplayFeature,
];
// A section of the screen that does contain display features and padding
const Rect subScreen = Rect.fromLTRB(42, 0, 82, 40);
late MediaQueryData subScreenMediaQuery;
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
size: size,
devicePixelRatio: devicePixelRatio,
textScaleFactor: textScaleFactor,
padding: padding,
viewPadding: viewPadding,
viewInsets: viewInsets,
alwaysUse24HourFormat: true,
accessibleNavigation: true,
invertColors: true,
disableAnimations: true,
boldText: true,
highContrast: true,
displayFeatures: displayFeatures,
),
child: Builder(
builder: (BuildContext context) {
return MediaQuery(
data: MediaQuery.of(context).removeDisplayFeatures(subScreen),
child: Builder(
builder: (BuildContext context) {
subScreenMediaQuery = MediaQuery.of(context);
return Container();
},
),
);
},
),
),
);
expect(subScreenMediaQuery.size, size);
expect(subScreenMediaQuery.devicePixelRatio, devicePixelRatio);
expect(subScreenMediaQuery.textScaleFactor, textScaleFactor);
expect(
subScreenMediaQuery.padding,
const EdgeInsets.only(top: 1.0, right: 2.0, bottom: 4.0),
);
expect(
subScreenMediaQuery.viewPadding,
const EdgeInsets.only(top: 6.0, left:4.0, right: 8.0, bottom: 12.0),
);
expect(
subScreenMediaQuery.viewInsets,
const EdgeInsets.only(top: 5.0, right: 6.0, bottom: 8.0),
);
expect(subScreenMediaQuery.alwaysUse24HourFormat, true);
expect(subScreenMediaQuery.accessibleNavigation, true);
expect(subScreenMediaQuery.invertColors, true);
expect(subScreenMediaQuery.disableAnimations, true);
expect(subScreenMediaQuery.boldText, true);
expect(subScreenMediaQuery.highContrast, true);
expect(subScreenMediaQuery.displayFeatures, <DisplayFeature>[cutoutDisplayFeature]);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment