1 /*
<lambda>null2 * Copyright (C) 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package com.android.systemui.qs.panels.ui.compose
18
19 import android.view.MotionEvent
20 import androidx.compose.foundation.layout.Arrangement.spacedBy
21 import androidx.compose.foundation.layout.Column
22 import androidx.compose.foundation.layout.PaddingValues
23 import androidx.compose.foundation.layout.Row
24 import androidx.compose.foundation.layout.Spacer
25 import androidx.compose.foundation.layout.fillMaxWidth
26 import androidx.compose.foundation.layout.requiredHeight
27 import androidx.compose.foundation.layout.wrapContentSize
28 import androidx.compose.foundation.layout.wrapContentWidth
29 import androidx.compose.foundation.pager.HorizontalPager
30 import androidx.compose.foundation.pager.PagerState
31 import androidx.compose.foundation.pager.rememberPagerState
32 import androidx.compose.foundation.shape.CornerSize
33 import androidx.compose.material3.MaterialTheme
34 import androidx.compose.runtime.Composable
35 import androidx.compose.runtime.LaunchedEffect
36 import androidx.compose.runtime.remember
37 import androidx.compose.runtime.snapshotFlow
38 import androidx.compose.ui.Alignment
39 import androidx.compose.ui.Modifier
40 import androidx.compose.ui.input.pointer.pointerInteropFilter
41 import androidx.compose.ui.res.integerResource
42 import androidx.compose.ui.unit.dp
43 import com.android.compose.animation.scene.ContentScope
44 import com.android.compose.modifiers.padding
45 import com.android.systemui.common.ui.compose.PagerDots
46 import com.android.systemui.compose.modifiers.sysuiResTag
47 import com.android.systemui.development.ui.compose.BuildNumber
48 import com.android.systemui.development.ui.viewmodel.BuildNumberViewModel
49 import com.android.systemui.lifecycle.rememberViewModel
50 import com.android.systemui.qs.panels.dagger.PaginatedBaseLayoutType
51 import com.android.systemui.qs.panels.ui.compose.Dimensions.FooterHeight
52 import com.android.systemui.qs.panels.ui.compose.Dimensions.InterPageSpacing
53 import com.android.systemui.qs.panels.ui.compose.toolbar.EditModeButton
54 import com.android.systemui.qs.panels.ui.viewmodel.PaginatedGridViewModel
55 import com.android.systemui.qs.panels.ui.viewmodel.TileViewModel
56 import com.android.systemui.qs.panels.ui.viewmodel.toolbar.EditModeButtonViewModel
57 import com.android.systemui.qs.ui.compose.borderOnFocus
58 import com.android.systemui.res.R
59 import javax.inject.Inject
60
61 class PaginatedGridLayout
62 @Inject
63 constructor(
64 private val viewModelFactory: PaginatedGridViewModel.Factory,
65 @PaginatedBaseLayoutType private val delegateGridLayout: PaginatableGridLayout,
66 ) : GridLayout by delegateGridLayout {
67 @Composable
68 override fun ContentScope.TileGrid(
69 tiles: List<TileViewModel>,
70 modifier: Modifier,
71 listening: () -> Boolean,
72 ) {
73 val viewModel =
74 rememberViewModel(traceName = "PaginatedGridLayout-TileGrid") {
75 viewModelFactory.create()
76 }
77
78 val columns = viewModel.columns
79 val rows = integerResource(R.integer.quick_settings_paginated_grid_num_rows)
80
81 val pages =
82 remember(tiles, columns, rows) {
83 delegateGridLayout.splitIntoPages(tiles, rows = rows, columns = columns)
84 }
85
86 val pagerState = rememberPagerState(0) { pages.size }
87
88 LaunchedEffect(listening, pagerState) {
89 snapshotFlow { listening() }
90 .collect {
91 // Whenever we go from not listening to listening, we should be in the first
92 // page. If we did this when going from listening to not listening, opening
93 // edit mode in second page will cause it to go to first page during the
94 // transition.
95 if (listening()) {
96 pagerState.scrollToPage(0)
97 }
98 }
99 }
100
101 // Used to track if this is currently in the first page or not, for animations
102 LaunchedEffect(key1 = pagerState) {
103 snapshotFlow { pagerState.currentPage == 0 }.collect { viewModel.inFirstPage = it }
104 }
105
106 Column {
107 val contentPaddingValue =
108 if (pages.size > 1) {
109 InterPageSpacing
110 } else {
111 0.dp
112 }
113 val contentPadding = PaddingValues(horizontal = contentPaddingValue)
114
115 /* Use negative padding equal with value equal to content padding. That way, each page
116 * layout extends to the sides, but the content is as if there was no padding. That
117 * way, the clipping bounds of the HorizontalPager extend beyond the tiles in each page.
118 */
119 HorizontalPager(
120 state = pagerState,
121 modifier =
122 Modifier.sysuiResTag("qs_pager")
123 .padding(horizontal = { -contentPaddingValue.roundToPx() })
124 .pointerInteropFilter { event ->
125 if (event.actionMasked == MotionEvent.ACTION_UP) {
126 viewModel.registerSideSwipeGesture()
127 }
128 false
129 },
130 contentPadding = contentPadding,
131 pageSpacing = if (pages.size > 1) InterPageSpacing else 0.dp,
132 beyondViewportPageCount = 1,
133 verticalAlignment = Alignment.Top,
134 ) {
135 val page = pages[it]
136
137 with(delegateGridLayout) { TileGrid(tiles = page, modifier = Modifier, listening) }
138 }
139 FooterBar(
140 buildNumberViewModelFactory = viewModel.buildNumberViewModelFactory,
141 pagerState = pagerState,
142 editButtonViewModelFactory = viewModel.editModeButtonViewModelFactory,
143 )
144 }
145 }
146 }
147
148 private object Dimensions {
149 val FooterHeight = 48.dp
150 val InterPageSpacing = 16.dp
151 }
152
153 @Composable
FooterBarnull154 private fun FooterBar(
155 buildNumberViewModelFactory: BuildNumberViewModel.Factory,
156 pagerState: PagerState,
157 editButtonViewModelFactory: EditModeButtonViewModel.Factory,
158 ) {
159 val editButtonViewModel =
160 rememberViewModel(traceName = "PaginatedGridLayout-editButtonViewModel") {
161 editButtonViewModelFactory.create()
162 }
163
164 // Use requiredHeight so it won't be squished if the view doesn't quite fit. As this is
165 // expected to be inside a scrollable container, this should not be an issue.
166 // Also, we construct the layout this way to do the following:
167 // * PagerDots is centered in the row, taking as much space as it needs.
168 // * On the start side, we place the BuildNumber, taking as much space as it needs, but
169 // constrained by the available space left over after PagerDots.
170 // * On the end side, we place the edit mode button, with the same constraints as for
171 // BuildNumber (but it will usually fit, as it's just a square button).
172 Row(
173 modifier = Modifier.requiredHeight(FooterHeight).fillMaxWidth(),
174 verticalAlignment = Alignment.CenterVertically,
175 horizontalArrangement = spacedBy(8.dp),
176 ) {
177 Row(Modifier.weight(1f)) {
178 BuildNumber(
179 viewModelFactory = buildNumberViewModelFactory,
180 textColor = MaterialTheme.colorScheme.onSurface,
181 modifier =
182 Modifier.borderOnFocus(
183 color = MaterialTheme.colorScheme.secondary,
184 cornerSize = CornerSize(1.dp),
185 )
186 .wrapContentSize(),
187 )
188 Spacer(modifier = Modifier.weight(1f))
189 }
190 PagerDots(
191 pagerState = pagerState,
192 activeColor = MaterialTheme.colorScheme.primary,
193 nonActiveColor = MaterialTheme.colorScheme.surfaceVariant,
194 modifier = Modifier.wrapContentWidth(),
195 )
196 Row(Modifier.weight(1f)) {
197 Spacer(modifier = Modifier.weight(1f))
198 EditModeButton(viewModel = editButtonViewModel)
199 }
200 }
201 }
202