| /* |
| * Copyright 2022 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package androidx.compose.foundation.demos |
| |
| import androidx.compose.foundation.Canvas |
| import androidx.compose.foundation.background |
| import androidx.compose.foundation.border |
| import androidx.compose.foundation.clickable |
| import androidx.compose.foundation.focusable |
| import androidx.compose.foundation.gestures.Orientation |
| import androidx.compose.foundation.gestures.Orientation.Horizontal |
| import androidx.compose.foundation.gestures.Orientation.Vertical |
| import androidx.compose.foundation.gestures.draggable |
| import androidx.compose.foundation.gestures.rememberDraggableState |
| import androidx.compose.foundation.horizontalScroll |
| import androidx.compose.foundation.layout.Box |
| import androidx.compose.foundation.layout.BoxScope |
| import androidx.compose.foundation.layout.Column |
| import androidx.compose.foundation.layout.Row |
| import androidx.compose.foundation.layout.fillMaxSize |
| import androidx.compose.foundation.layout.fillMaxWidth |
| import androidx.compose.foundation.layout.padding |
| import androidx.compose.foundation.layout.wrapContentSize |
| import androidx.compose.foundation.rememberScrollState |
| import androidx.compose.foundation.verticalScroll |
| import androidx.compose.material.Button |
| import androidx.compose.material.Text |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.getValue |
| import androidx.compose.runtime.mutableIntStateOf |
| import androidx.compose.runtime.mutableStateOf |
| import androidx.compose.runtime.remember |
| import androidx.compose.runtime.setValue |
| import androidx.compose.ui.Alignment |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.focus.FocusRequester |
| import androidx.compose.ui.focus.focusRequester |
| import androidx.compose.ui.focus.onFocusChanged |
| import androidx.compose.ui.geometry.Offset |
| import androidx.compose.ui.geometry.Size |
| import androidx.compose.ui.graphics.Color |
| import androidx.compose.ui.layout.Layout |
| import androidx.compose.ui.layout.layout |
| import androidx.compose.ui.layout.onSizeChanged |
| import androidx.compose.ui.tooling.preview.Preview |
| import androidx.compose.ui.unit.Constraints |
| import androidx.compose.ui.unit.IntOffset |
| import androidx.compose.ui.unit.IntSize |
| import androidx.compose.ui.unit.dp |
| import androidx.compose.ui.unit.toSize |
| import kotlin.math.roundToInt |
| |
| @Preview(showBackground = true) |
| @Composable |
| fun ScrollableFocusedChildDemo() { |
| val resizableState = remember { ResizableState() } |
| var reverseScrolling by remember { mutableStateOf(false) } |
| |
| Column { |
| Text( |
| "Click on the blue boxes to give them focus. Drag the handles around the black box " + |
| "to change its size. Try adjusting size while the box inside the resizable area " + |
| "is focused, and while it's not focused." |
| ) |
| |
| Row { |
| Button( |
| onClick = { |
| resizableState.cutInHalf() |
| }, |
| modifier = Modifier.weight(1f) |
| ) { |
| Text("½ size") |
| } |
| Button( |
| onClick = { |
| resizableState.resetToMaxSize() |
| }, |
| modifier = Modifier.weight(1f) |
| ) { |
| Text("Max size") |
| } |
| Button( |
| onClick = { |
| reverseScrolling = !reverseScrolling |
| }, |
| modifier = Modifier.weight(1f) |
| ) { |
| Text("Scroll: ${if (reverseScrolling) "backward" else "forward"}") |
| } |
| } |
| |
| FocusGrabber(Modifier.fillMaxWidth()) |
| |
| var maxViewportSize by remember { mutableStateOf(IntSize.Zero) } |
| Resizable( |
| resizableState, |
| Modifier |
| .weight(1f) |
| .fillMaxWidth() |
| .onSizeChanged { maxViewportSize = it } |
| ) { |
| Box( |
| Modifier |
| .border(2.dp, Color.Black) |
| .verticalScroll(rememberScrollState(), reverseScrolling = reverseScrolling) |
| .horizontalScroll(rememberScrollState(), reverseScrolling = reverseScrolling) |
| ) { |
| Box( |
| Modifier |
| // Ensure there's always something to scroll by making the scrollable |
| // content a multiple of the available screen space. |
| .size { maxViewportSize.toSize() * 1.5f } |
| .background(Color.LightGray) |
| .wrapContentSize(align = Alignment.Center) |
| ) { |
| FocusGrabber() |
| } |
| } |
| } |
| } |
| } |
| |
| @Composable |
| fun FocusGrabber(modifier: Modifier = Modifier) { |
| val focusRequester = remember { FocusRequester() } |
| var hasFocus by remember { mutableStateOf(false) } |
| Text( |
| text = if (hasFocus) "Focused" else "Click to focus", |
| color = if (hasFocus) Color.White else Color.Black, |
| modifier = modifier |
| .clickable { focusRequester.requestFocus() } |
| .onFocusChanged { hasFocus = it.hasFocus } |
| .focusRequester(focusRequester) |
| .focusable() |
| .border(3.dp, Color.Blue) |
| .then(if (hasFocus) Modifier.background(Color.Blue) else Modifier) |
| .padding(8.dp) |
| ) |
| } |
| |
| private class ResizableState { |
| var widthOverride by mutableIntStateOf(-1) |
| var heightOverride by mutableIntStateOf(-1) |
| |
| fun resetToMaxSize() { |
| widthOverride = -1 |
| heightOverride = -1 |
| } |
| |
| fun cutInHalf() { |
| widthOverride /= 2 |
| heightOverride /= 2 |
| } |
| } |
| |
| @Suppress("NAME_SHADOWING") |
| @Composable |
| private fun Resizable( |
| state: ResizableState, |
| modifier: Modifier, |
| content: @Composable BoxScope.() -> Unit |
| ) { |
| val handleThickness = 48.dp |
| |
| Layout( |
| modifier = modifier, |
| content = { |
| ResizeHandle( |
| orientation = Horizontal, |
| onDrag = { state.heightOverride += it.roundToInt() } |
| ) |
| ResizeHandle( |
| orientation = Vertical, |
| onDrag = { state.widthOverride += it.roundToInt() } |
| ) |
| Box(propagateMinConstraints = true, content = content) |
| } |
| ) { measurables, constraints -> |
| with(state) { |
| val (horizontalHandleMeasurable, verticalHandleMeasurable, contentMeasurable) = |
| measurables |
| val handleThickness = handleThickness.roundToPx() |
| widthOverride = if (widthOverride < 0) { |
| constraints.maxWidth |
| } else { |
| widthOverride.coerceIn(handleThickness, constraints.maxWidth) |
| } |
| heightOverride = if (heightOverride < 0) { |
| constraints.maxHeight |
| } else { |
| heightOverride.coerceIn(handleThickness, constraints.maxHeight) |
| } |
| val contentConstraints = Constraints.fixed( |
| width = widthOverride - handleThickness, |
| height = heightOverride - handleThickness |
| ) |
| |
| val contentPlaceable = contentMeasurable.measure(contentConstraints) |
| val horizontalHandlePlaceable = horizontalHandleMeasurable.measure( |
| Constraints.fixed(width = widthOverride, height = handleThickness) |
| ) |
| val verticalHandlePlaceable = verticalHandleMeasurable.measure( |
| Constraints.fixed(width = handleThickness, height = heightOverride) |
| ) |
| |
| layout(constraints.maxWidth, constraints.maxHeight) { |
| contentPlaceable.place(IntOffset.Zero) |
| horizontalHandlePlaceable.place( |
| x = 0, |
| y = contentPlaceable.height |
| ) |
| verticalHandlePlaceable.place( |
| x = contentPlaceable.width, |
| y = 0 |
| ) |
| } |
| } |
| } |
| } |
| |
| @Suppress("NAME_SHADOWING") |
| @Composable |
| private fun ResizeHandle(orientation: Orientation, onDrag: (Float) -> Unit) { |
| val dragState = rememberDraggableState(onDrag) |
| val lineWidth = 24.dp |
| val lineSpacing = 4.dp |
| val lineWeight = 1.dp |
| |
| Canvas( |
| Modifier |
| .fillMaxSize() |
| .draggable(dragState, if (orientation == Horizontal) Vertical else Horizontal) |
| ) { |
| val lineWidth = lineWidth.toPx() |
| val lineSpacing = lineSpacing.toPx() |
| val lineWeight = lineWeight.toPx() |
| |
| if (orientation == Horizontal) { |
| val startX = center.x - lineWidth / 2 |
| val endX = center.x + lineWidth / 2 |
| val y1 = center.y - lineSpacing / 2 |
| val y2 = center.y + lineSpacing / 2 |
| drawLine(Color.Black, Offset(startX, y1), Offset(endX, y1), lineWeight) |
| drawLine(Color.Black, Offset(startX, y2), Offset(endX, y2), lineWeight) |
| } else { |
| val startY = center.y - lineWidth / 2 |
| val endY = center.y + lineWidth / 2 |
| val x1 = center.x - lineSpacing / 2 |
| val x2 = center.x + lineSpacing / 2 |
| drawLine(Color.Black, Offset(x1, startY), Offset(x1, endY), lineWeight) |
| drawLine(Color.Black, Offset(x2, startY), Offset(x2, endY), lineWeight) |
| } |
| } |
| } |
| |
| /** Measures the modified node to be the size returned from [size]. */ |
| private fun Modifier.size(size: () -> Size): Modifier = layout { measurable, _ -> |
| val constraints = size().let { |
| Constraints.fixed(it.width.roundToInt(), it.height.roundToInt()) |
| } |
| val placeable = measurable.measure(constraints) |
| layout(placeable.width, placeable.height) { |
| placeable.place(IntOffset.Zero) |
| } |
| } |