dev
dart 162 lines 5.03 KB
Raw
1 import 'package:flutter/foundation.dart';
2 import 'package:flutter/material.dart';
3 import 'package:flutter/rendering.dart';
4
5 class LineTabSwitcher extends StatefulWidget {
6 const LineTabSwitcher({
7 super.key,
8 required this.tabs,
9 required this.onTabChange,
10 required this.selectedTab,
11 });
12
13 final List<String> tabs;
14 final void Function(int index) onTabChange;
15 final int selectedTab;
16
17 @override
18 State<LineTabSwitcher> createState() => _LineTabSwitcherState();
19 }
20
21 class _LineTabSwitcherState extends State<LineTabSwitcher> {
22 List<GlobalKey> textWidgetKeys = [];
23 List<Size> textWidgetSizes = [];
24 double totalWidth = 0;
25 final double itemPadding = 16;
26 final double barPadding = 8;
27 bool textWidgetsMeasured = false;
28
29 @override
30 void didUpdateWidget(covariant LineTabSwitcher oldWidget) {
31 super.didUpdateWidget(oldWidget);
32
33 if (!listEquals(oldWidget.tabs, widget.tabs)) {
34 textWidgetKeys = List.generate(widget.tabs.length, (_) => GlobalKey());
35 textWidgetSizes = [];
36 totalWidth = 0;
37 textWidgetsMeasured = false;
38
39 WidgetsBinding.instance.addPostFrameCallback((_) => measure());
40 }
41 }
42
43 double _calcBarLeft() {
44 double left = 0;
45
46 if (textWidgetKeys.isEmpty || textWidgetSizes.isEmpty) {
47 return 0;
48 }
49
50 for (int i = 0; i < widget.selectedTab; i++) {
51 left += textWidgetSizes[i].width + itemPadding;
52 }
53
54 left += barPadding;
55
56 return left;
57 }
58
59 @override
60 void initState() {
61 super.initState();
62 textWidgetKeys = List.generate(widget.tabs.length, (index) => GlobalKey());
63 WidgetsBinding.instance.addPostFrameCallback((_) => measure());
64 }
65
66 void measure() {
67 if (textWidgetsMeasured) return;
68 setState(() {
69 textWidgetSizes =
70 textWidgetKeys.map((k) => k.currentContext!.size).whereType<Size>().toList();
71
72 for (final size in textWidgetSizes) {
73 totalWidth += size.width + itemPadding;
74 }
75 totalWidth += barPadding;
76 textWidgetsMeasured = true;
77 });
78 }
79
80 @override
81 Widget build(BuildContext context) {
82 if (!textWidgetsMeasured) {
83 WidgetsBinding.instance.addPostFrameCallback((_) => measure());
84 }
85
86 return Column(
87 crossAxisAlignment: CrossAxisAlignment.start,
88 mainAxisAlignment: MainAxisAlignment.start,
89 children: [
90 Container(
91 height: 28,
92 child: Row(
93 children: widget.tabs.map((item) {
94 final index = widget.tabs.indexOf(item);
95
96 // One tab = one node: the visible label merges into a button node
97 // that also carries the selected state of this tab.
98 return MergeSemantics(
99 child: Semantics(
100 button: true,
101 selected: widget.selectedTab == index,
102 inMutuallyExclusiveGroup: true,
103 child: GestureDetector(
104 onTap: () {
105 widget.onTabChange(index);
106 },
107 child: Column(
108 mainAxisSize: MainAxisSize.min,
109 mainAxisAlignment: MainAxisAlignment.center,
110 children: [
111 AnimatedDefaultTextStyle(
112 duration: Duration(milliseconds: 150),
113 style: DefaultTextStyle.of(context).style.copyWith(
114 inherit: true,
115 fontSize: 16,
116 fontWeight: FontWeight.w500,
117 color: widget.selectedTab == index
118 ? Theme.of(context).colorScheme.onSurface
119 : Theme.of(context).colorScheme.onSurfaceVariant,
120 ),
121 child: Padding(
122 padding: EdgeInsets.symmetric(horizontal: itemPadding / 2),
123 child: Text(
124 item,
125 key: textWidgetKeys[index],
126 ),
127 ),
128 ),
129 ],
130 ),
131 ),
132 ),
133 );
134 }).toList())),
135 if (widget.tabs.length > 1)
136 Container(
137 width: totalWidth,
138 height: 2,
139 child: Stack(
140 children: [
141 AnimatedPositioned(
142 curve: Curves.easeOut,
143 left: _calcBarLeft(),
144 bottom: 0,
145 duration: Duration(milliseconds: 150),
146 child: AnimatedSize(
147 duration: Duration(milliseconds: 150),
148 child: Container(
149 height: 2,
150 width:
151 textWidgetSizes.isEmpty ? 0 : textWidgetSizes[widget.selectedTab].width,
152 color: Theme.of(context).colorScheme.onSurface,
153 ),
154 ),
155 ),
156 ],
157 ),
158 ),
159 ],
160 );
161 }
162 }