Skip to content

Commit 5d5ba13

Browse files
authored
Fix focus traversal (#2)
* Add test for wrong behavior * fix #1 * reorganize tests * fix assertion error caught by tests * properly unregister listeners * version bump * bump version used by example * fix format * simplify OffscreenFocusExclusionBuilder
1 parent 6806ca2 commit 5d5ba13

File tree

7 files changed

+194
-8
lines changed

7 files changed

+194
-8
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 1.0.1
2+
3+
* fix focus traversal
4+
15
## 1.0.0
26

37
Initial release, please refer to the readme and the example for available functionality.

example/pubspec.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ packages:
6565
path: ".."
6666
relative: true
6767
source: path
68-
version: "0.0.1"
68+
version: "1.0.1"
6969
leak_tracker:
7070
dependency: transitive
7171
description:

lib/inline_tab_view.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:flutter/material.dart';
22
import 'package:inline_tab_view/src/inline_tab_view_widget.dart';
3+
import 'package:inline_tab_view/src/offscreen_focus_exclusion_builder.dart';
34

45
/// A height adjusting widget switcher that displays the widget which
56
/// corresponds to the currently selected tab.
@@ -60,9 +61,13 @@ class InlineTabView extends StatelessWidget {
6061

6162
return ClipRect(
6263
clipBehavior: clipBehavior,
63-
child: InlineTabViewWidget(
64+
child: OffscreenFocusExclusionBuilder(
6465
controller: controller!,
6566
children: children,
67+
builder: (List<Widget> children) => InlineTabViewWidget(
68+
controller: controller,
69+
children: children,
70+
),
6671
),
6772
);
6873
}

lib/src/inline_tab_view_render_object.dart

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ class InlineTabViewRenderObject extends RenderBox
9393
// TODO: consider pointer cancel event
9494
_attemptSnap();
9595
_dragStartPos = null;
96-
markNeedsLayout();
9796
} else if (event is PointerMoveEvent) {
9897
final delta = event.position.dx - _dragStartPos!;
9998
double offset = delta / size.width;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import 'package:flutter/material.dart';
2+
3+
/// Only allow focus of the visible widget and block focus during animation by
4+
/// wrapping children in [ExcludeFocus].
5+
class OffscreenFocusExclusionBuilder extends StatelessWidget {
6+
/// Only allow focus of the visible widget and block focus during animation.
7+
const OffscreenFocusExclusionBuilder({
8+
super.key,
9+
required this.controller,
10+
required this.children,
11+
required this.builder,
12+
});
13+
14+
/// This widget's selection and animation state.
15+
final TabController controller;
16+
17+
/// One widget per tab.
18+
///
19+
/// Its length must match the length of the [TabBar.tabs]
20+
/// list, as well as the [controller]'s [TabController.length].
21+
final List<Widget> children;
22+
23+
/// Child builder, takes the wrapped children.
24+
final Widget Function(List<Widget> children) builder;
25+
26+
@override
27+
Widget build(BuildContext context) => ListenableBuilder(
28+
listenable: controller,
29+
builder: (BuildContext context, Widget? _child) {
30+
if (controller.indexIsChanging)
31+
return ExcludeFocus(child: builder(children));
32+
return builder([
33+
for (int i = 0; i < children.length; i++)
34+
ExcludeFocus(
35+
excluding: i != controller.index,
36+
child: children[i],
37+
)
38+
]);
39+
},
40+
);
41+
}

pubspec.yaml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: inline_tab_view
2-
description: "A TabBarView that can be nested in scrollables."
3-
version: 1.0.0
2+
description: "A TabBarView that can be nested in scrollables while sticking to flutter best practices and avoiding hacks."
3+
version: 1.0.1
44
repository: https://github.com/derdilla/inline_tab_view
55

66
environment:
@@ -14,6 +14,3 @@ dependencies:
1414
dev_dependencies:
1515
flutter_test:
1616
sdk: flutter
17-
18-
19-
# flutter:
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:inline_tab_view/inline_tab_view.dart';
4+
import 'package:inline_tab_view/src/offscreen_focus_exclusion_builder.dart';
5+
6+
void main() {
7+
testWidgets('hidden children are not focusable', (tester) async {
8+
final controller = TabController(length: 2, vsync: const TestVSync());
9+
addTearDown(controller.dispose);
10+
11+
final leadingFocus = FocusNode();
12+
addTearDown(leadingFocus.dispose);
13+
final trailingFocus = FocusNode();
14+
addTearDown(trailingFocus.dispose);
15+
final tab1Wid1Focus = FocusNode();
16+
addTearDown(tab1Wid1Focus.dispose);
17+
final tab1Wid2Focus = FocusNode();
18+
addTearDown(tab1Wid2Focus.dispose);
19+
final tab1Wid3Focus = FocusNode();
20+
addTearDown(tab1Wid3Focus.dispose);
21+
final tab2Wid1Focus = FocusNode();
22+
addTearDown(tab2Wid1Focus.dispose);
23+
final tab2Wid2Focus = FocusNode();
24+
addTearDown(tab2Wid2Focus.dispose);
25+
final tab2Wid3Focus = FocusNode();
26+
addTearDown(tab2Wid3Focus.dispose);
27+
28+
await tester.pumpWidget(MaterialApp(
29+
home: Column(
30+
children: [
31+
Focus(
32+
key: Key('leading'),
33+
focusNode: leadingFocus,
34+
child: SizedBox.square(dimension: 10)),
35+
InlineTabView(
36+
controller: controller,
37+
children: [
38+
Column(
39+
children: [
40+
Focus(
41+
key: Key('Tab 1 - 1'),
42+
focusNode: tab1Wid1Focus,
43+
child: SizedBox.square(dimension: 10)),
44+
Focus(
45+
key: Key('Tab 1 - 2'),
46+
focusNode: tab1Wid2Focus,
47+
child: SizedBox.square(dimension: 10)),
48+
Focus(
49+
key: Key('Tab 1 - 3'),
50+
focusNode: tab1Wid3Focus,
51+
child: SizedBox.square(dimension: 10)),
52+
],
53+
),
54+
Column(
55+
children: [
56+
Focus(
57+
key: Key('Tab 2 - 1'),
58+
focusNode: tab2Wid1Focus,
59+
child: SizedBox.square(dimension: 10)),
60+
Focus(
61+
key: Key('Tab 2 - 2'),
62+
focusNode: tab2Wid2Focus,
63+
child: SizedBox.square(dimension: 10)),
64+
Focus(
65+
key: Key('Tab 2 - 3'),
66+
focusNode: tab2Wid3Focus,
67+
child: SizedBox.square(dimension: 10)),
68+
],
69+
)
70+
],
71+
),
72+
Focus(
73+
key: Key('trailing'),
74+
focusNode: trailingFocus,
75+
child: SizedBox.square(dimension: 10)),
76+
],
77+
),
78+
));
79+
expect(find.byType(OffscreenFocusExclusionBuilder), findsOneWidget);
80+
81+
tab1Wid1Focus.requestFocus();
82+
await tester.pumpAndSettle();
83+
expect(leadingFocus.hasFocus, false);
84+
expect(tab1Wid1Focus.hasFocus, true);
85+
expect(tab1Wid2Focus.hasFocus, false);
86+
expect(tab1Wid3Focus.hasFocus, false);
87+
expect(tab2Wid1Focus.hasFocus, false);
88+
expect(tab2Wid2Focus.hasFocus, false);
89+
expect(tab2Wid3Focus.hasFocus, false);
90+
expect(trailingFocus.hasFocus, false);
91+
92+
// it doesn't (shouldn't) matter which node is used to request the next focus.
93+
leadingFocus.nextFocus();
94+
await tester.pumpAndSettle();
95+
expect(leadingFocus.hasFocus, false);
96+
expect(tab1Wid1Focus.hasFocus, false);
97+
expect(tab1Wid2Focus.hasFocus, true);
98+
expect(tab1Wid3Focus.hasFocus, false);
99+
expect(tab2Wid1Focus.hasFocus, false);
100+
expect(tab2Wid2Focus.hasFocus, false);
101+
expect(tab2Wid3Focus.hasFocus, false);
102+
expect(trailingFocus.hasFocus, false);
103+
104+
leadingFocus.nextFocus();
105+
await tester.pumpAndSettle();
106+
expect(leadingFocus.hasFocus, false);
107+
expect(tab1Wid1Focus.hasFocus, false);
108+
expect(tab1Wid2Focus.hasFocus, false);
109+
expect(tab1Wid3Focus.hasFocus, true);
110+
expect(tab2Wid1Focus.hasFocus, false);
111+
expect(tab2Wid2Focus.hasFocus, false);
112+
expect(tab2Wid3Focus.hasFocus, false);
113+
expect(trailingFocus.hasFocus, false);
114+
115+
leadingFocus.nextFocus();
116+
await tester.pumpAndSettle();
117+
expect(leadingFocus.hasFocus, false);
118+
expect(tab1Wid1Focus.hasFocus, false);
119+
expect(tab1Wid2Focus.hasFocus, false);
120+
expect(tab1Wid3Focus.hasFocus, false);
121+
expect(tab2Wid1Focus.hasFocus, false);
122+
expect(tab2Wid2Focus.hasFocus, false);
123+
expect(tab2Wid3Focus.hasFocus, false);
124+
expect(trailingFocus.hasFocus, true);
125+
126+
leadingFocus.previousFocus();
127+
leadingFocus.previousFocus();
128+
leadingFocus.previousFocus();
129+
leadingFocus.previousFocus();
130+
await tester.pumpAndSettle();
131+
expect(leadingFocus.hasFocus, true);
132+
expect(tab1Wid1Focus.hasFocus, false);
133+
expect(tab1Wid2Focus.hasFocus, false);
134+
expect(tab1Wid3Focus.hasFocus, false);
135+
expect(tab2Wid1Focus.hasFocus, false);
136+
expect(tab2Wid2Focus.hasFocus, false);
137+
expect(tab2Wid3Focus.hasFocus, false);
138+
expect(trailingFocus.hasFocus, false);
139+
});
140+
}

0 commit comments

Comments
 (0)