11package shark.explorer.app
22
3+ import androidx.compose.animation.AnimatedVisibility
4+ import androidx.compose.animation.core.MutableTransitionState
5+ import androidx.compose.animation.core.tween
6+ import androidx.compose.animation.expandHorizontally
7+ import androidx.compose.animation.fadeIn
38import androidx.compose.foundation.ExperimentalFoundationApi
49import androidx.compose.foundation.PointerMatcher
510import androidx.compose.foundation.background
611import androidx.compose.foundation.clickable
7- import androidx.compose.foundation.horizontalScroll
812import androidx.compose.foundation.layout.Arrangement
13+ import androidx.compose.foundation.layout.FlowRow
914import androidx.compose.foundation.layout.Row
1015import androidx.compose.foundation.layout.fillMaxWidth
1116import androidx.compose.foundation.layout.padding
1217import androidx.compose.foundation.layout.widthIn
1318import androidx.compose.foundation.onClick
14- import androidx.compose.foundation.rememberScrollState
1519import androidx.compose.foundation.selection.selectable
1620import androidx.compose.material3.MaterialTheme
1721import androidx.compose.material3.Text
1822import androidx.compose.runtime.Composable
23+ import androidx.compose.runtime.key
24+ import androidx.compose.runtime.remember
1925import androidx.compose.ui.Alignment
2026import androidx.compose.ui.Modifier
2127import androidx.compose.ui.input.pointer.PointerButton
@@ -27,16 +33,18 @@ import shark.explorer.Place
2733import shark.explorer.Tabs
2834
2935/* *
30- * The tabs open in this window, left to right, as a strip under the bar that opens them.
36+ * The tabs open in this window, left to right and wrapping onto a line of its own once a line is full, as
37+ * a strip under the bar that opens them.
3138 *
3239 * Every tab is closeable, the last one included: a window with no tab still holds the heap dump it spent
3340 * seconds reading, and the bar above is one click from a tab again. See [Tabs].
3441 *
35- * The strip scrolls rather than shrinking its tabs to nothing , because what a tab is called — which class,
42+ * Neither shrinking its tabs nor scrolling them off the edge , because what a tab is called — which class,
3643 * and which instance of it — is the whole of how a strip of a dozen instances of one class is one you can
37- * pick out of.
44+ * pick out of, and a tab you have to go looking for is one you may as well not have opened. So the strip
45+ * grows down into the window instead: what it costs is the view's height, and only for someone who has
46+ * opened enough tabs to be reading across them anyway.
3847 */
39- @OptIn(ExperimentalFoundationApi ::class )
4048@Composable
4149internal fun TabStrip (
4250 tabs : Tabs ,
@@ -49,53 +57,99 @@ internal fun TabStrip(
4957 if (tabs.tabs.isEmpty()) {
5058 return
5159 }
52- Row (
53- modifier.fillMaxWidth().horizontalScroll(rememberScrollState()). padding(horizontal = 4 .dp),
60+ FlowRow (
61+ modifier.fillMaxWidth().padding(horizontal = 4 .dp),
5462 horizontalArrangement = Arrangement .spacedBy(2 .dp),
55- verticalAlignment = Alignment .Bottom
63+ verticalArrangement = Arrangement .spacedBy(2 .dp),
64+ itemVerticalAlignment = Alignment .Bottom
5665 ) {
5766 tabs.tabs.forEach { tab ->
58- val isSelected = tab.id == tabs.selectedId
59- Row (
60- Modifier
61- .background(
62- if (isSelected) {
63- MaterialTheme .colorScheme.surface
64- } else {
65- MaterialTheme .colorScheme.surfaceVariant
66- }
67- )
68- // Middle clicking a tab closes it, which is the other half of middle clicking opening one.
69- .onClick(matcher = PointerMatcher .mouse(PointerButton .Tertiary )) { onClose(tab.id) }
70- // Selectable rather than clickable, because a strip of these is a set with one of them on: it
71- // is also what tells a tab apart from the button and the chain row that lead to the same place.
72- .selectable(selected = isSelected, role = Role .Tab ) { onSelect(tab.id) }
73- .padding(start = 8 .dp, end = 4 .dp, top = 4 .dp, bottom = 4 .dp)
74- .widthIn(max = MAX_TAB_WIDTH ),
75- horizontalArrangement = Arrangement .spacedBy(4 .dp),
76- verticalAlignment = Alignment .CenterVertically
77- ) {
78- Text (
79- titleOf(tab.place),
80- style = MaterialTheme .typography.bodySmall,
81- fontWeight = if (isSelected) FontWeight .Bold else FontWeight .Normal ,
82- maxLines = 1 ,
83- overflow = TextOverflow .Ellipsis ,
84- modifier = Modifier .weight(1f , fill = false )
85- )
86- // Its own click target rather than a modifier on the tab, so that closing a tab is never
87- // selecting it first: a strip where closing the fourth tab shows you the fourth tab is a strip
88- // that fights back.
89- Text (
90- CLOSE_TAB ,
91- Modifier .clickable { onClose(tab.id) }.padding(horizontal = 2 .dp),
92- style = MaterialTheme .typography.bodySmall
67+ // Keyed on the tab rather than on where it is in the strip, because a tab opens *beside* the one it
68+ // was opened from: without this, inserting one in the middle would hand its state to the tab that
69+ // used to be there, and the one that grew in would be whichever ended up last.
70+ key(tab.id) {
71+ TabView (
72+ title = titleOf(tab.place),
73+ isSelected = tab.id == tabs.selectedId,
74+ onSelect = { onSelect(tab.id) },
75+ onClose = { onClose(tab.id) }
9376 )
9477 }
9578 }
9679 }
9780}
9881
82+ /* *
83+ * One tab, growing into the strip as it opens.
84+ *
85+ * Widening from nothing rather than appearing at full width, the way a browser does it, because a tab
86+ * opened in the background is one nothing else on screen announces: what says a middle click did anything
87+ * at all is the strip moving over to make room. It also puts the tab where it went — beside the one it was
88+ * opened from, which is not where a strip that pops a tab in reads as having put it.
89+ *
90+ * Closing is not animated, deliberately: a strip that holds a tab open for a moment after it was closed is
91+ * one where the tab under the pointer isn't the tab a second click closes.
92+ */
93+ @OptIn(ExperimentalFoundationApi ::class )
94+ @Composable
95+ private fun TabView (
96+ title : String ,
97+ isSelected : Boolean ,
98+ onSelect : () -> Unit ,
99+ onClose : () -> Unit
100+ ) {
101+ // Starting closed and opening on the first composition, which is what makes this animate on the way in:
102+ // a tab that was already open when the strip drew it has nothing to animate.
103+ val opening = remember { MutableTransitionState (false ) }
104+ opening.targetState = true
105+ AnimatedVisibility (
106+ visibleState = opening,
107+ // From the start edge, so the tab holds the spot it was inserted at and pushes the ones after it along,
108+ // rather than sliding in from under its neighbour.
109+ enter = expandHorizontally(
110+ animationSpec = tween(TAB_OPEN_MILLIS ),
111+ expandFrom = Alignment .Start
112+ ) + fadeIn(tween(TAB_OPEN_MILLIS ))
113+ ) {
114+ Row (
115+ Modifier
116+ .background(
117+ if (isSelected) {
118+ MaterialTheme .colorScheme.surface
119+ } else {
120+ MaterialTheme .colorScheme.surfaceVariant
121+ }
122+ )
123+ // Middle clicking a tab closes it, which is the other half of middle clicking opening one.
124+ .onClick(matcher = PointerMatcher .mouse(PointerButton .Tertiary )) { onClose() }
125+ // Selectable rather than clickable, because a strip of these is a set with one of them on: it
126+ // is also what tells a tab apart from the button and the chain row that lead to the same place.
127+ .selectable(selected = isSelected, role = Role .Tab ) { onSelect() }
128+ .padding(start = 8 .dp, end = 4 .dp, top = 4 .dp, bottom = 4 .dp)
129+ .widthIn(max = MAX_TAB_WIDTH ),
130+ horizontalArrangement = Arrangement .spacedBy(4 .dp),
131+ verticalAlignment = Alignment .CenterVertically
132+ ) {
133+ Text (
134+ title,
135+ style = MaterialTheme .typography.bodySmall,
136+ fontWeight = if (isSelected) FontWeight .Bold else FontWeight .Normal ,
137+ maxLines = 1 ,
138+ overflow = TextOverflow .Ellipsis ,
139+ modifier = Modifier .weight(1f , fill = false )
140+ )
141+ // Its own click target rather than a modifier on the tab, so that closing a tab is never
142+ // selecting it first: a strip where closing the fourth tab shows you the fourth tab is a strip
143+ // that fights back.
144+ Text (
145+ CLOSE_TAB ,
146+ Modifier .clickable { onClose() }.padding(horizontal = 2 .dp),
147+ style = MaterialTheme .typography.bodySmall
148+ )
149+ }
150+ }
151+ }
152+
99153/* *
100154 * Shown where a tab would be once the last one has been closed.
101155 *
@@ -110,3 +164,11 @@ internal const val CLOSE_TAB = "✕"
110164
111165/* * Wide enough for a class name and an address, short enough that ten tabs are all still on the strip. */
112166private val MAX_TAB_WIDTH = 220 .dp
167+
168+ /* *
169+ * How long a tab takes to grow into the strip.
170+ *
171+ * Long enough to be read as the strip making room, short enough that a middle click and the tab being
172+ * there are one thing: a tab you have to wait for is one you would rather had just appeared.
173+ */
174+ private const val TAB_OPEN_MILLIS = 150
0 commit comments