Skip to content

Commit 4ceeca0

Browse files
authored
Added example for Magnifier and TextMagnifier (#110218)
1 parent dc2618b commit 4ceeca0

File tree

19 files changed

+494
-139
lines changed

19 files changed

+494
-139
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
7+
void main() => runApp(const MyApp());
8+
9+
class MyApp extends StatefulWidget {
10+
const MyApp({super.key});
11+
static const Size loupeSize = Size(200, 200);
12+
13+
@override
14+
State<MyApp> createState() => _MyAppState();
15+
}
16+
17+
class _MyAppState extends State<MyApp> {
18+
Offset dragGesturePositon = Offset.zero;
19+
20+
@override
21+
Widget build(BuildContext context) {
22+
return MaterialApp(
23+
home: Scaffold(
24+
body: Center(
25+
child: Column(
26+
mainAxisAlignment: MainAxisAlignment.center,
27+
children: <Widget>[
28+
const Text('Drag on the logo!'),
29+
RepaintBoundary(
30+
child: Stack(
31+
children: <Widget>[
32+
GestureDetector(
33+
onPanUpdate: (DragUpdateDetails details) => setState(
34+
() {
35+
dragGesturePositon = details.localPosition;
36+
},
37+
),
38+
child: const FlutterLogo(size: 200),
39+
),
40+
Positioned(
41+
left: dragGesturePositon.dx,
42+
top: dragGesturePositon.dy,
43+
child: const RawMagnifier(
44+
decoration: MagnifierDecoration(
45+
shape: CircleBorder(
46+
side: BorderSide(color: Colors.pink, width: 3),
47+
),
48+
),
49+
size: Size(100, 100),
50+
magnificationScale: 2,
51+
),
52+
)
53+
],
54+
),
55+
),
56+
],
57+
),
58+
),
59+
),
60+
);
61+
}
62+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/foundation.dart';
6+
import 'package:flutter/material.dart';
7+
8+
void main() => runApp(const MyApp(text: 'Hello world!'));
9+
10+
class MyApp extends StatelessWidget {
11+
const MyApp({
12+
super.key,
13+
this.textDirection = TextDirection.ltr,
14+
required this.text,
15+
});
16+
17+
final TextDirection textDirection;
18+
final String text;
19+
20+
static const Size loupeSize = Size(200, 200);
21+
22+
@override
23+
Widget build(BuildContext context) {
24+
return MaterialApp(
25+
home: Scaffold(
26+
body: Padding(
27+
padding: const EdgeInsets.symmetric(horizontal: 48.0),
28+
child: Center(
29+
child: TextField(
30+
textDirection: textDirection,
31+
// Create a custom magnifier configuration that
32+
// this `TextField` will use to build a magnifier with.
33+
magnifierConfiguration: TextMagnifierConfiguration(
34+
magnifierBuilder: (_, __, ValueNotifier<MagnifierInfo> magnifierInfo) => CustomMagnifier(
35+
magnifierInfo: magnifierInfo,
36+
),
37+
),
38+
controller: TextEditingController(text: text),
39+
),
40+
),
41+
),
42+
),
43+
);
44+
}
45+
}
46+
47+
class CustomMagnifier extends StatelessWidget {
48+
const CustomMagnifier({super.key, required this.magnifierInfo});
49+
50+
static const Size magnifierSize = Size(200, 200);
51+
52+
// This magnifier will consume some text data and position itself
53+
// based on the info in the magnifier.
54+
final ValueNotifier<MagnifierInfo> magnifierInfo;
55+
56+
@override
57+
Widget build(BuildContext context) {
58+
// Use a value listenable builder because we want to rebuild
59+
// every time the text selection info changes.
60+
// `CustomMagnifier` could also be a `StatefulWidget` and call `setState`
61+
// when `magnifierInfo` updates. This would be useful for more complex
62+
// positioning cases.
63+
return ValueListenableBuilder<MagnifierInfo>(
64+
valueListenable: magnifierInfo,
65+
builder: (BuildContext context,
66+
MagnifierInfo currentMagnifierInfo, _) {
67+
// We want to position the magnifier at the global position of the gesture.
68+
Offset magnifierPosition = currentMagnifierInfo.globalGesturePosition;
69+
70+
// You may use the `MagnifierInfo` however you'd like:
71+
// In this case, we make sure the magnifier never goes out of the current line bounds.
72+
magnifierPosition = Offset(
73+
clampDouble(
74+
magnifierPosition.dx,
75+
currentMagnifierInfo.currentLineBoundaries.left,
76+
currentMagnifierInfo.currentLineBoundaries.right,
77+
),
78+
clampDouble(
79+
magnifierPosition.dy,
80+
currentMagnifierInfo.currentLineBoundaries.top,
81+
currentMagnifierInfo.currentLineBoundaries.bottom,
82+
),
83+
);
84+
85+
// Finally, align the magnifier to the bottom center. The inital anchor is
86+
// the top left, so subtract bottom center alignment.
87+
magnifierPosition -= Alignment.bottomCenter.alongSize(magnifierSize);
88+
89+
return Positioned(
90+
left: magnifierPosition.dx,
91+
top: magnifierPosition.dy,
92+
child: RawMagnifier(
93+
magnificationScale: 2,
94+
// The focal point starts at the center of the magnifier.
95+
// We probably want to point below the magnifier, so
96+
// offset the focal point by half the magnifier height.
97+
focalPointOffset: Offset(0, magnifierSize.height / 2),
98+
// Decorate it however we'd like!
99+
decoration: const MagnifierDecoration(
100+
shape: StarBorder(
101+
side: BorderSide(
102+
color: Colors.green,
103+
width: 2,
104+
),
105+
),
106+
),
107+
size: magnifierSize,
108+
),
109+
);
110+
});
111+
}
112+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter_api_samples/widgets/magnifier/magnifier.0.dart'
7+
as example;
8+
import 'package:flutter_test/flutter_test.dart';
9+
10+
void main() {
11+
testWidgets('should update magnifier position on drag', (WidgetTester tester) async {
12+
await tester.pumpWidget(const example.MyApp());
13+
14+
Matcher isPositionedAt(Offset at) {
15+
return isA<Positioned>().having(
16+
(Positioned positioned) => Offset(positioned.left!, positioned.top!),
17+
'magnifier position',
18+
at,
19+
);
20+
}
21+
22+
expect(
23+
tester.widget(find.byType(Positioned)),
24+
isPositionedAt(Offset.zero),
25+
);
26+
27+
final Offset centerOfFlutterLogo = tester.getCenter(find.byType(Positioned));
28+
final Offset topLeftOfFlutterLogo = tester.getTopLeft(find.byType(FlutterLogo));
29+
30+
const Offset dragDistance = Offset(10, 10);
31+
32+
await tester.dragFrom(centerOfFlutterLogo, dragDistance);
33+
await tester.pump();
34+
35+
expect(
36+
tester.widget(find.byType(Positioned)),
37+
// Need to adjust by the topleft since the position is local.
38+
isPositionedAt((centerOfFlutterLogo - topLeftOfFlutterLogo) + dragDistance),
39+
);
40+
});
41+
42+
testWidgets('should match golden', (WidgetTester tester) async {
43+
await tester.pumpWidget(const example.MyApp());
44+
45+
final Offset centerOfFlutterLogo = tester.getCenter(find.byType(Positioned));
46+
const Offset dragDistance = Offset(10, 10);
47+
48+
await tester.dragFrom(centerOfFlutterLogo, dragDistance);
49+
await tester.pump();
50+
51+
await expectLater(
52+
find.byType(RepaintBoundary).last,
53+
matchesGoldenFile('magnifier.0_test.png'),
54+
);
55+
});
56+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter/rendering.dart';
7+
import 'package:flutter_api_samples/widgets/text_magnifier/text_magnifier.0.dart'
8+
as example;
9+
import 'package:flutter_test/flutter_test.dart';
10+
11+
List<TextSelectionPoint> _globalize(
12+
Iterable<TextSelectionPoint> points, RenderBox box) {
13+
return points.map<TextSelectionPoint>((TextSelectionPoint point) {
14+
return TextSelectionPoint(
15+
box.localToGlobal(point.point),
16+
point.direction,
17+
);
18+
}).toList();
19+
}
20+
21+
RenderEditable _findRenderEditable<T extends State<StatefulWidget>>(WidgetTester tester) {
22+
return (tester.state(find.byType(TextField))
23+
as TextSelectionGestureDetectorBuilderDelegate)
24+
.editableTextKey
25+
.currentState!
26+
.renderEditable;
27+
}
28+
29+
Offset _textOffsetToPosition<T extends State<StatefulWidget>>(WidgetTester tester, int offset) {
30+
final RenderEditable renderEditable = _findRenderEditable(tester);
31+
32+
final List<TextSelectionPoint> endpoints = renderEditable
33+
.getEndpointsForSelection(
34+
TextSelection.collapsed(offset: offset),
35+
)
36+
.map<TextSelectionPoint>((TextSelectionPoint point) => TextSelectionPoint(
37+
renderEditable.localToGlobal(point.point),
38+
point.direction,
39+
))
40+
.toList();
41+
42+
return endpoints[0].point + const Offset(0.0, -2.0);
43+
}
44+
45+
void main() {
46+
const Duration durationBetweenActons = Duration(milliseconds: 20);
47+
const String defaultText = 'I am a magnifier, fear me!';
48+
49+
Future<void> showMagnifier(WidgetTester tester, String characterToTapOn) async {
50+
final Offset tapOffset = _textOffsetToPosition(tester, defaultText.indexOf(characterToTapOn));
51+
52+
// Double tap 'Magnifier' word to show the selection handles.
53+
final TestGesture testGesture = await tester.startGesture(tapOffset);
54+
await tester.pump(durationBetweenActons);
55+
await testGesture.up();
56+
await tester.pump(durationBetweenActons);
57+
await testGesture.down(tapOffset);
58+
await tester.pump(durationBetweenActons);
59+
await testGesture.up();
60+
await tester.pumpAndSettle();
61+
62+
final TextSelection selection = tester
63+
.firstWidget<TextField>(find.byType(TextField))
64+
.controller!
65+
.selection;
66+
67+
final RenderEditable renderEditable = _findRenderEditable(tester);
68+
final List<TextSelectionPoint> endpoints = _globalize(
69+
renderEditable.getEndpointsForSelection(selection),
70+
renderEditable,
71+
);
72+
73+
final Offset handlePos = endpoints.last.point + const Offset(10.0, 10.0);
74+
75+
final TestGesture gesture = await tester.startGesture(handlePos);
76+
77+
await gesture.moveTo(
78+
_textOffsetToPosition(
79+
tester,
80+
defaultText.length - 2,
81+
),
82+
);
83+
await tester.pump();
84+
}
85+
86+
testWidgets('should show custom magnifier on drag', (WidgetTester tester) async {
87+
await tester.pumpWidget(const example.MyApp(text: defaultText));
88+
89+
await showMagnifier(tester, 'e');
90+
expect(find.byType(example.CustomMagnifier), findsOneWidget);
91+
92+
await expectLater(
93+
find.byType(example.MyApp),
94+
matchesGoldenFile('text_magnifier.0_test.png'),
95+
);
96+
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android }));
97+
98+
99+
for (final TextDirection textDirection in TextDirection.values) {
100+
testWidgets('should show custom magnifier in $textDirection', (WidgetTester tester) async {
101+
final String text = textDirection == TextDirection.rtl ? 'أثارت زر' : defaultText;
102+
final String textToTapOn = textDirection == TextDirection.rtl ? 'ت' : 'e';
103+
104+
await tester.pumpWidget(example.MyApp(textDirection: textDirection, text: text));
105+
106+
await showMagnifier(tester, textToTapOn);
107+
108+
expect(find.byType(example.CustomMagnifier), findsOneWidget);
109+
});
110+
}
111+
}

0 commit comments

Comments
 (0)