Skip to content

Commit

Permalink
Fix focus traversal (#2)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
derdilla authored Dec 3, 2024
1 parent 6806ca2 commit 5d5ba13
Show file tree
Hide file tree
Showing 7 changed files with 194 additions and 8 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 1.0.1

* fix focus traversal

## 1.0.0

Initial release, please refer to the readme and the example for available functionality.
2 changes: 1 addition & 1 deletion example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ packages:
path: ".."
relative: true
source: path
version: "0.0.1"
version: "1.0.1"
leak_tracker:
dependency: transitive
description:
Expand Down
7 changes: 6 additions & 1 deletion lib/inline_tab_view.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:inline_tab_view/src/inline_tab_view_widget.dart';
import 'package:inline_tab_view/src/offscreen_focus_exclusion_builder.dart';

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

return ClipRect(
clipBehavior: clipBehavior,
child: InlineTabViewWidget(
child: OffscreenFocusExclusionBuilder(
controller: controller!,
children: children,
builder: (List<Widget> children) => InlineTabViewWidget(
controller: controller,
children: children,
),
),
);
}
Expand Down
1 change: 0 additions & 1 deletion lib/src/inline_tab_view_render_object.dart
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ class InlineTabViewRenderObject extends RenderBox
// TODO: consider pointer cancel event
_attemptSnap();
_dragStartPos = null;
markNeedsLayout();
} else if (event is PointerMoveEvent) {
final delta = event.position.dx - _dragStartPos!;
double offset = delta / size.width;
Expand Down
41 changes: 41 additions & 0 deletions lib/src/offscreen_focus_exclusion_builder.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';

/// Only allow focus of the visible widget and block focus during animation by
/// wrapping children in [ExcludeFocus].
class OffscreenFocusExclusionBuilder extends StatelessWidget {
/// Only allow focus of the visible widget and block focus during animation.
const OffscreenFocusExclusionBuilder({
super.key,
required this.controller,
required this.children,
required this.builder,
});

/// This widget's selection and animation state.
final TabController controller;

/// One widget per tab.
///
/// Its length must match the length of the [TabBar.tabs]
/// list, as well as the [controller]'s [TabController.length].
final List<Widget> children;

/// Child builder, takes the wrapped children.
final Widget Function(List<Widget> children) builder;

@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: controller,
builder: (BuildContext context, Widget? _child) {
if (controller.indexIsChanging)
return ExcludeFocus(child: builder(children));
return builder([
for (int i = 0; i < children.length; i++)
ExcludeFocus(
excluding: i != controller.index,
child: children[i],
)
]);
},
);
}
7 changes: 2 additions & 5 deletions pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: inline_tab_view
description: "A TabBarView that can be nested in scrollables."
version: 1.0.0
description: "A TabBarView that can be nested in scrollables while sticking to flutter best practices and avoiding hacks."
version: 1.0.1
repository: https://github.com/derdilla/inline_tab_view

environment:
Expand All @@ -14,6 +14,3 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter


# flutter:
140 changes: 140 additions & 0 deletions test/src/offscreen_focus_exclusion_builder.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:inline_tab_view/inline_tab_view.dart';
import 'package:inline_tab_view/src/offscreen_focus_exclusion_builder.dart';

void main() {
testWidgets('hidden children are not focusable', (tester) async {
final controller = TabController(length: 2, vsync: const TestVSync());
addTearDown(controller.dispose);

final leadingFocus = FocusNode();
addTearDown(leadingFocus.dispose);
final trailingFocus = FocusNode();
addTearDown(trailingFocus.dispose);
final tab1Wid1Focus = FocusNode();
addTearDown(tab1Wid1Focus.dispose);
final tab1Wid2Focus = FocusNode();
addTearDown(tab1Wid2Focus.dispose);
final tab1Wid3Focus = FocusNode();
addTearDown(tab1Wid3Focus.dispose);
final tab2Wid1Focus = FocusNode();
addTearDown(tab2Wid1Focus.dispose);
final tab2Wid2Focus = FocusNode();
addTearDown(tab2Wid2Focus.dispose);
final tab2Wid3Focus = FocusNode();
addTearDown(tab2Wid3Focus.dispose);

await tester.pumpWidget(MaterialApp(
home: Column(
children: [
Focus(
key: Key('leading'),
focusNode: leadingFocus,
child: SizedBox.square(dimension: 10)),
InlineTabView(
controller: controller,
children: [
Column(
children: [
Focus(
key: Key('Tab 1 - 1'),
focusNode: tab1Wid1Focus,
child: SizedBox.square(dimension: 10)),
Focus(
key: Key('Tab 1 - 2'),
focusNode: tab1Wid2Focus,
child: SizedBox.square(dimension: 10)),
Focus(
key: Key('Tab 1 - 3'),
focusNode: tab1Wid3Focus,
child: SizedBox.square(dimension: 10)),
],
),
Column(
children: [
Focus(
key: Key('Tab 2 - 1'),
focusNode: tab2Wid1Focus,
child: SizedBox.square(dimension: 10)),
Focus(
key: Key('Tab 2 - 2'),
focusNode: tab2Wid2Focus,
child: SizedBox.square(dimension: 10)),
Focus(
key: Key('Tab 2 - 3'),
focusNode: tab2Wid3Focus,
child: SizedBox.square(dimension: 10)),
],
)
],
),
Focus(
key: Key('trailing'),
focusNode: trailingFocus,
child: SizedBox.square(dimension: 10)),
],
),
));
expect(find.byType(OffscreenFocusExclusionBuilder), findsOneWidget);

tab1Wid1Focus.requestFocus();
await tester.pumpAndSettle();
expect(leadingFocus.hasFocus, false);
expect(tab1Wid1Focus.hasFocus, true);
expect(tab1Wid2Focus.hasFocus, false);
expect(tab1Wid3Focus.hasFocus, false);
expect(tab2Wid1Focus.hasFocus, false);
expect(tab2Wid2Focus.hasFocus, false);
expect(tab2Wid3Focus.hasFocus, false);
expect(trailingFocus.hasFocus, false);

// it doesn't (shouldn't) matter which node is used to request the next focus.
leadingFocus.nextFocus();
await tester.pumpAndSettle();
expect(leadingFocus.hasFocus, false);
expect(tab1Wid1Focus.hasFocus, false);
expect(tab1Wid2Focus.hasFocus, true);
expect(tab1Wid3Focus.hasFocus, false);
expect(tab2Wid1Focus.hasFocus, false);
expect(tab2Wid2Focus.hasFocus, false);
expect(tab2Wid3Focus.hasFocus, false);
expect(trailingFocus.hasFocus, false);

leadingFocus.nextFocus();
await tester.pumpAndSettle();
expect(leadingFocus.hasFocus, false);
expect(tab1Wid1Focus.hasFocus, false);
expect(tab1Wid2Focus.hasFocus, false);
expect(tab1Wid3Focus.hasFocus, true);
expect(tab2Wid1Focus.hasFocus, false);
expect(tab2Wid2Focus.hasFocus, false);
expect(tab2Wid3Focus.hasFocus, false);
expect(trailingFocus.hasFocus, false);

leadingFocus.nextFocus();
await tester.pumpAndSettle();
expect(leadingFocus.hasFocus, false);
expect(tab1Wid1Focus.hasFocus, false);
expect(tab1Wid2Focus.hasFocus, false);
expect(tab1Wid3Focus.hasFocus, false);
expect(tab2Wid1Focus.hasFocus, false);
expect(tab2Wid2Focus.hasFocus, false);
expect(tab2Wid3Focus.hasFocus, false);
expect(trailingFocus.hasFocus, true);

leadingFocus.previousFocus();
leadingFocus.previousFocus();
leadingFocus.previousFocus();
leadingFocus.previousFocus();
await tester.pumpAndSettle();
expect(leadingFocus.hasFocus, true);
expect(tab1Wid1Focus.hasFocus, false);
expect(tab1Wid2Focus.hasFocus, false);
expect(tab1Wid3Focus.hasFocus, false);
expect(tab2Wid1Focus.hasFocus, false);
expect(tab2Wid2Focus.hasFocus, false);
expect(tab2Wid3Focus.hasFocus, false);
expect(trailingFocus.hasFocus, false);
});
}

0 comments on commit 5d5ba13

Please sign in to comment.