Unverified Commit 4a4fa2a7 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

Cupertino RTL (#13273)

Fixes the remaining known issues with widgets supporting RTL.
parent 6493c8b4
......@@ -39,7 +39,7 @@ class _CupertinoSliderDemoState extends State<CupertinoSliderDemo> {
});
}
),
const Text('Cupertino Continuous'),
new Text('Cupertino Continuous: ${_value.toStringAsFixed(1)}'),
]
),
new Column(
......@@ -56,7 +56,7 @@ class _CupertinoSliderDemoState extends State<CupertinoSliderDemo> {
});
}
),
const Text('Cupertino Discrete'),
new Text('Cupertino Discrete: $_discreteValue'),
]
),
],
......
......@@ -31,16 +31,17 @@ class CupertinoIcons {
/// The icon font used for Cupertino icons.
static const String iconFont = 'CupertinoIcons';
/// The dependent package providing the Cupertino icons font.
static const String iconFontPackage = 'cupertino_icons';
// Manually maintained list
// Manually maintained list.
/// A thin left chevron.
static const IconData left_chevron = const IconData(0xf3f0, fontFamily: iconFont, fontPackage: iconFontPackage);
static const IconData left_chevron = const IconData(0xf3f0, fontFamily: iconFont, fontPackage: iconFontPackage, matchTextDirection: true);
/// A thin right chevron.
static const IconData right_chevron = const IconData(0xf3f2, fontFamily: iconFont, fontPackage: iconFontPackage);
static const IconData right_chevron = const IconData(0xf3f2, fontFamily: iconFont, fontPackage: iconFontPackage, matchTextDirection: true);
/// iOS style share icon with an arrow pointing up from a box.
static const IconData share = const IconData(0xf4ca, fontFamily: iconFont, fontPackage: iconFontPackage);
......@@ -79,14 +80,14 @@ class CupertinoIcons {
static const IconData check_mark_circled = const IconData(0xf3fe, fontFamily: iconFont, fontPackage: iconFontPackage);
/// A thicker left chevron used in iOS for the nav bar back button.
static const IconData back = const IconData(0xf3cf, fontFamily: iconFont, fontPackage: iconFontPackage);
static const IconData back = const IconData(0xf3cf, fontFamily: iconFont, fontPackage: iconFontPackage, matchTextDirection: true);
/// Outline of a simple front-facing house.
static const IconData home = const IconData(0xf447, fontFamily: iconFont, fontPackage: iconFontPackage);
/// A right facing shopping cart outline.
/// A right-facing shopping cart outline.
static const IconData shopping_cart = const IconData(0xf3f7, fontFamily: iconFont, fontPackage: iconFontPackage);
/// 3 solid dots.
/// Three solid dots.
static const IconData ellipsis = const IconData(0xf46a, fontFamily: iconFont, fontPackage: iconFontPackage);
}
......@@ -376,7 +376,7 @@ class _CupertinoPersistentNavigationBar extends StatelessWidget implements Prefe
? new Container(
height: _kNavBarPersistentHeight,
width: _kNavBarBackButtonTapWidth,
alignment: Alignment.centerLeft,
alignment: AlignmentDirectional.centerStart,
child: const Icon(CupertinoIcons.back, size: 34.0,)
)
: const Text('Close'),
......@@ -394,10 +394,10 @@ class _CupertinoPersistentNavigationBar extends StatelessWidget implements Prefe
size: 22.0,
),
child: new Padding(
padding: new EdgeInsets.only(
padding: new EdgeInsetsDirectional.only(
top: MediaQuery.of(context).padding.top,
left: useBackButton ? _kNavBarBackButtonPadding : _kNavBarEdgePadding,
right: _kNavBarEdgePadding,
start: useBackButton ? _kNavBarBackButtonPadding : _kNavBarEdgePadding,
end: _kNavBarEdgePadding,
),
child: new MediaQuery.removePadding(
context: context,
......
......@@ -35,9 +35,9 @@ final DecorationTween _kGradientShadowTween = new DecorationTween(
end: const _CupertinoEdgeShadowDecoration(
edgeGradient: const LinearGradient(
// Spans 5% of the page.
begin: const Alignment(0.90, 0.0),
end: Alignment.centerRight,
// Eyeballed gradient used to mimic a drop shadow on the left side only.
begin: const AlignmentDirectional(0.90, 0.0),
end: AlignmentDirectional.centerEnd,
// Eyeballed gradient used to mimic a drop shadow on the start side only.
colors: const <Color>[
const Color(0x00000000),
const Color(0x04000000),
......@@ -293,31 +293,31 @@ class CupertinoPageTransition extends StatelessWidget {
@required Animation<double> primaryRouteAnimation,
@required Animation<double> secondaryRouteAnimation,
@required this.child,
bool linearTransition,
}) :
_primaryPositionAnimation = linearTransition
? _kRightMiddleTween.animate(primaryRouteAnimation)
: _kRightMiddleTween.animate(
new CurvedAnimation(
parent: primaryRouteAnimation,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
)
),
_secondaryPositionAnimation = _kMiddleLeftTween.animate(
new CurvedAnimation(
parent: secondaryRouteAnimation,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
)
),
_primaryShadowAnimation = _kGradientShadowTween.animate(
new CurvedAnimation(
parent: primaryRouteAnimation,
curve: Curves.easeOut,
)
),
super(key: key);
@required bool linearTransition,
}) : assert(linearTransition != null),
_primaryPositionAnimation = linearTransition
? _kRightMiddleTween.animate(primaryRouteAnimation)
: _kRightMiddleTween.animate(
new CurvedAnimation(
parent: primaryRouteAnimation,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
)
),
_secondaryPositionAnimation = _kMiddleLeftTween.animate(
new CurvedAnimation(
parent: secondaryRouteAnimation,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
)
),
_primaryShadowAnimation = _kGradientShadowTween.animate(
new CurvedAnimation(
parent: primaryRouteAnimation,
curve: Curves.easeOut,
)
),
super(key: key);
// When this page is coming in to cover another page.
final Animation<Offset> _primaryPositionAnimation;
......@@ -330,12 +330,16 @@ class CupertinoPageTransition extends StatelessWidget {
@override
Widget build(BuildContext context) {
assert(debugCheckHasDirectionality(context));
final TextDirection textDirection = Directionality.of(context);
// TODO(ianh): tell the transform to be un-transformed for hit testing
// but not while being controlled by a gesture.
return new SlideTransition(
position: _secondaryPositionAnimation,
textDirection: textDirection,
child: new SlideTransition(
position: _primaryPositionAnimation,
textDirection: textDirection,
child: new DecoratedBoxTransition(
decoration: _primaryShadowAnimation,
child: child,
......@@ -382,6 +386,9 @@ class CupertinoFullscreenDialogTransition extends StatelessWidget {
/// This widget provides a gesture recognizer which, when it determines the
/// route can be closed with a back gesture, creates the controller and
/// feeds it the input from the gesture recognizer.
///
/// The gesture data is converted from absolute coordinates to logical
/// coordinates by this widget.
class _CupertinoBackGestureDetector extends StatefulWidget {
const _CupertinoBackGestureDetector({
Key key,
......@@ -433,13 +440,13 @@ class _CupertinoBackGestureDetectorState extends State<_CupertinoBackGestureDete
void _handleDragUpdate(DragUpdateDetails details) {
assert(mounted);
assert(_backGestureController != null);
_backGestureController.dragUpdate(details.primaryDelta / context.size.width);
_backGestureController.dragUpdate(_convertToLogical(details.primaryDelta / context.size.width));
}
void _handleDragEnd(DragEndDetails details) {
assert(mounted);
assert(_backGestureController != null);
_backGestureController.dragEnd(details.velocity.pixelsPerSecond.dx / context.size.width);
_backGestureController.dragEnd(_convertToLogical(details.velocity.pixelsPerSecond.dx / context.size.width));
_backGestureController = null;
}
......@@ -456,14 +463,25 @@ class _CupertinoBackGestureDetectorState extends State<_CupertinoBackGestureDete
_recognizer.addPointer(event);
}
double _convertToLogical(double value) {
switch (Directionality.of(context)) {
case TextDirection.rtl:
return -value;
case TextDirection.ltr:
return value;
}
return null;
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasDirectionality(context));
return new Stack(
fit: StackFit.passthrough,
children: <Widget>[
widget.child,
new Positioned(
left: 0.0,
new PositionedDirectional(
start: 0.0,
width: _kBackGestureWidth,
top: 0.0,
bottom: 0.0,
......@@ -484,6 +502,9 @@ class _CupertinoBackGestureDetectorState extends State<_CupertinoBackGestureDete
/// by a [_CupertinoBackGestureDetector] widget, which then also feeds it input
/// from the gesture. It controls the animation controller owned by the route,
/// based on the input provided by the gesture detector.
///
/// This class works entirely in logical coordinates (0.0 is new page dismissed,
/// 1.0 is new page on top).
class _CupertinoBackGestureController {
/// Creates a controller for an iOS-style back gesture.
///
......@@ -553,9 +574,15 @@ class _CupertinoBackGestureController {
}
}
/// A custom [Decoration] used to paint an extra shadow on the left edge of the
/// box it's decorating. It's like a [BoxDecoration] with only a gradient except
/// it paints to the left of the box instead of behind the box.
// A custom [Decoration] used to paint an extra shadow on the start edge of the
// box it's decorating. It's like a [BoxDecoration] with only a gradient except
// it paints on the start side of the box instead of behind the box.
//
// The [edgeGradient] will be given a [TextDirection] when its shader is
// created, and so can be direction-sensitive; in this file we set it to a
// gradient that uses an AlignmentDirectional to position the gradient on the
// end edge of the gradient's box (which will be the edge adjacent to the start
// edge of the actual box we're supposed to paint in).
class _CupertinoEdgeShadowDecoration extends Decoration {
const _CupertinoEdgeShadowDecoration({ this.edgeGradient });
......@@ -604,18 +631,14 @@ class _CupertinoEdgeShadowDecoration extends Decoration {
@override
bool operator ==(dynamic other) {
if (identical(this, other))
return true;
if (other.runtimeType != _CupertinoEdgeShadowDecoration)
if (runtimeType != other.runtimeType)
return false;
final _CupertinoEdgeShadowDecoration typedOther = other;
return edgeGradient == typedOther.edgeGradient;
}
@override
int get hashCode {
return edgeGradient.hashCode;
}
int get hashCode => edgeGradient.hashCode;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
......@@ -628,7 +651,7 @@ class _CupertinoEdgeShadowDecoration extends Decoration {
class _CupertinoEdgeShadowPainter extends BoxPainter {
_CupertinoEdgeShadowPainter(
this._decoration,
VoidCallback onChange
VoidCallback onChange,
) : assert(_decoration != null),
super(onChange);
......@@ -640,11 +663,21 @@ class _CupertinoEdgeShadowPainter extends BoxPainter {
if (gradient == null)
return;
// The drawable space for the gradient is a rect with the same size as
// its parent box one box width to the left of the box.
final Rect rect =
(offset & configuration.size).translate(-configuration.size.width, 0.0);
// its parent box one box width on the start side of the box.
final TextDirection textDirection = configuration.textDirection;
assert(textDirection != null);
double deltaX;
switch (textDirection) {
case TextDirection.rtl:
deltaX = configuration.size.width;
break;
case TextDirection.ltr:
deltaX = -configuration.size.width;
break;
}
final Rect rect = (offset & configuration.size).translate(deltaX, 0.0);
final Paint paint = new Paint()
..shader = gradient.createShader(rect);
..shader = gradient.createShader(rect, textDirection: textDirection);
canvas.drawRect(rect, paint);
}
......
......@@ -343,13 +343,13 @@ class _RenderCupertinoSlider extends RenderConstrainedBox {
switch (textDirection) {
case TextDirection.rtl:
visualPosition = 1.0 - _position.value;
leftColor = _kTrackColor;
rightColor = _activeColor;
leftColor = _activeColor;
rightColor = _kTrackColor;
break;
case TextDirection.ltr:
visualPosition = _position.value;
leftColor = _activeColor;
rightColor = _kTrackColor;
leftColor = _kTrackColor;
rightColor = _activeColor;
break;
}
......
......@@ -101,6 +101,12 @@ class _AnimatedState extends State<AnimatedWidget> {
/// The translation is expressed as a [Offset] scaled to the child's size. For
/// example, an [Offset] with a `dx` of 0.25 will result in a horizontal
/// translation of one quarter the width of the child.
///
/// By default, the offsets are applied in the coordinate system of the canvas
/// (so positive x offsets move the child towards the right). If a
/// [textDirection] is provided, then the offsets are applied in the reading
/// direction, so in right-to-left text, positive x offsets move towards the
/// left, and in left-to-right text, positive x offsets move towards the right.
class SlideTransition extends AnimatedWidget {
/// Creates a fractional translation transition.
///
......@@ -109,6 +115,7 @@ class SlideTransition extends AnimatedWidget {
Key key,
@required Animation<Offset> position,
this.transformHitTests: true,
this.textDirection,
this.child,
}) : assert(position != null),
super(key: key, listenable: position);
......@@ -117,9 +124,22 @@ class SlideTransition extends AnimatedWidget {
///
/// If the current value of the position animation is `(dx, dy)`, the child
/// will be translated horizontally by `width * dx` and vertically by
/// `height * dy`.
/// `height * dy`, after applying the [textDirection] if available.
Animation<Offset> get position => listenable;
/// The direction to use for the x offset described by the [position].
///
/// If [textDirection] is null, the x offset is applied in the coordinate
/// system of the canvas (so positive x offsets move the child towards the
/// right).
///
/// If [textDirection] is [TextDirection.rtl], the x offset is applied in the
/// reading direction such that x offsets move the child towards the left.
///
/// If [textDirection] is [TextDirection.ltr], the x offset is applied in the
/// reading direction such that x offsets move the child towards the right.
final TextDirection textDirection;
/// Whether hit testing should be affected by the slide animation.
///
/// If false, hit testing will proceed as if the child was not translated at
......@@ -133,8 +153,11 @@ class SlideTransition extends AnimatedWidget {
@override
Widget build(BuildContext context) {
Offset offset = position.value;
if (textDirection == TextDirection.rtl)
offset = new Offset(-offset.dx, offset.dy);
return new FractionalTranslation(
translation: position.value,
translation: offset,
transformHitTests: transformHitTests,
child: child,
);
......
......@@ -6,7 +6,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('test iOS page transition', (WidgetTester tester) async {
testWidgets('test iOS page transition (LTR)', (WidgetTester tester) async {
await tester.pumpWidget(
new WidgetsApp(
color: const Color(0xFFFFFFFF),
......@@ -74,6 +74,78 @@ void main() {
expect(widget1InitialTopLeft, equals(widget1TransientTopLeft));
});
testWidgets('test iOS page transition (RTL)', (WidgetTester tester) async {
await tester.pumpWidget(
new WidgetsApp(
localizationsDelegates: <LocalizationsDelegate<dynamic>>[
const RtlOverrideWidgetsDelegate(),
],
color: const Color(0xFFFFFFFF),
onGenerateRoute: (RouteSettings settings) {
return new CupertinoPageRoute<Null>(
settings: settings,
builder: (BuildContext context) {
final String pageNumber = settings.name == '/' ? '1' : '2';
return new Center(child: new Text('Page $pageNumber'));
}
);
},
),
);
await tester.pump(); // to load the localization, since it doesn't use a synchronous future
final Offset widget1InitialTopLeft = tester.getTopLeft(find.text('Page 1'));
tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next');
await tester.pump();
await tester.pump(const Duration(milliseconds: 150));
Offset widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1'));
Offset widget2TopLeft = tester.getTopLeft(find.text('Page 2'));
// Page 1 is moving to the right.
expect(widget1TransientTopLeft.dx, greaterThan(widget1InitialTopLeft.dx));
// Page 1 isn't moving vertically.
expect(widget1TransientTopLeft.dy, equals(widget1InitialTopLeft.dy));
// iOS transition is horizontal only.
expect(widget1InitialTopLeft.dy, equals(widget2TopLeft.dy));
// Page 2 is coming in from the left.
expect(widget2TopLeft.dx, lessThan(widget1InitialTopLeft.dx));
await tester.pumpAndSettle();
// Page 2 covers page 1.
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), isOnstage);
tester.state<NavigatorState>(find.byType(Navigator)).pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1'));
widget2TopLeft = tester.getTopLeft(find.text('Page 2'));
// Page 1 is coming back from the right.
expect(widget1TransientTopLeft.dx, greaterThan(widget1InitialTopLeft.dx));
// Page 1 isn't moving vertically.
expect(widget1TransientTopLeft.dy, equals(widget1InitialTopLeft.dy));
// iOS transition is horizontal only.
expect(widget1InitialTopLeft.dy, equals(widget2TopLeft.dy));
// Page 2 is leaving towards the left.
expect(widget2TopLeft.dx, lessThan(widget1InitialTopLeft.dx));
await tester.pumpAndSettle();
expect(find.text('Page 1'), isOnstage);
expect(find.text('Page 2'), findsNothing);
widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1'));
// Page 1 is back where it started.
expect(widget1InitialTopLeft, equals(widget1TransientTopLeft));
});
testWidgets('test iOS fullscreen dialog transition', (WidgetTester tester) async {
await tester.pumpWidget(
new WidgetsApp(
......@@ -142,7 +214,7 @@ void main() {
expect(widget1InitialTopLeft, equals(widget1TransientTopLeft));
});
testWidgets('test only edge swipes work', (WidgetTester tester) async {
testWidgets('test only edge swipes work (LTR)', (WidgetTester tester) async {
await tester.pumpWidget(
new WidgetsApp(
color: const Color(0xFFFFFFFF),
......@@ -176,13 +248,113 @@ void main() {
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), isOnstage);
// Now drag from the edge.
// Drag from the right to the left.
gesture = await tester.startGesture(const Offset(795.0, 200.0));
await gesture.moveBy(const Offset(-300.0, 0.0));
await tester.pump();
// Nothing should happen.
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), isOnstage);
// Drag from the right to the further right.
gesture = await tester.startGesture(const Offset(795.0, 200.0));
await gesture.moveBy(const Offset(300.0, 0.0));
await tester.pump();
// Nothing should happen.
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), isOnstage);
// Now drag from the left edge.
gesture = await tester.startGesture(const Offset(5.0, 200.0));
await gesture.moveBy(const Offset(300.0, 0.0));
await tester.pump();
// Page 1 is now visible.
expect(find.text('Page 1'), isOnstage);
expect(find.text('Page 2'), isOnstage);
});
testWidgets('test only edge swipes work (RTL)', (WidgetTester tester) async {
await tester.pumpWidget(
new WidgetsApp(
localizationsDelegates: <LocalizationsDelegate<dynamic>>[
const RtlOverrideWidgetsDelegate(),
],
color: const Color(0xFFFFFFFF),
onGenerateRoute: (RouteSettings settings) {
return new CupertinoPageRoute<Null>(
settings: settings,
builder: (BuildContext context) {
final String pageNumber = settings.name == '/' ? '1' : '2';
return new Center(child: new Text('Page $pageNumber'));
}
);
},
),
);
await tester.pump(); // to load the localization, since it doesn't use a synchronous future
tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next');
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
// Page 2 covers page 1.
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), isOnstage);
// Drag from the middle to the left.
TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0));
await gesture.moveBy(const Offset(-300.0, 0.0));
await tester.pump();
// Nothing should happen.
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), isOnstage);
// Drag from the left to the right.
gesture = await tester.startGesture(const Offset(5.0, 200.0));
await gesture.moveBy(const Offset(300.0, 0.0));
await tester.pump();
// Nothing should happen.
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), isOnstage);
// Drag from the left to the further left.
gesture = await tester.startGesture(const Offset(5.0, 200.0));
await gesture.moveBy(const Offset(-300.0, 0.0));
await tester.pump();
// Nothing should happen.
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), isOnstage);
// Now drag from the right edge.
gesture = await tester.startGesture(const Offset(795.0, 200.0));
await gesture.moveBy(const Offset(-300.0, 0.0));
await tester.pump();
// Page 1 is now visible.
expect(find.text('Page 1'), isOnstage);
expect(find.text('Page 2'), isOnstage);
});
}
class RtlOverrideWidgetsDelegate extends LocalizationsDelegate<WidgetsLocalizations> {
const RtlOverrideWidgetsDelegate();
@override
bool isSupported(Locale locale) => true;
@override
Future<WidgetsLocalizations> load(Locale locale) async => const RtlOverrideWidgetsLocalization();
@override
bool shouldReload(LocalizationsDelegate<WidgetsLocalizations> oldDelegate) => false;
}
class RtlOverrideWidgetsLocalization implements WidgetsLocalizations {
const RtlOverrideWidgetsLocalization();
@override
TextDirection get textDirection => TextDirection.rtl;
}
\ No newline at end of file
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