diff --git a/CanonicalLayouts/feed-compose/.gitignore b/CanonicalLayouts/feed-compose/.gitignore index aa724b770..82e4fd4bb 100644 --- a/CanonicalLayouts/feed-compose/.gitignore +++ b/CanonicalLayouts/feed-compose/.gitignore @@ -7,6 +7,7 @@ /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml +/.idea/deploymentTargetDropDown.xml .DS_Store /build /captures diff --git a/CanonicalLayouts/feed-compose/app/build.gradle b/CanonicalLayouts/feed-compose/app/build.gradle index 2d10cc24d..8bfd23593 100644 --- a/CanonicalLayouts/feed-compose/app/build.gradle +++ b/CanonicalLayouts/feed-compose/app/build.gradle @@ -17,6 +17,7 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id 'kotlin-parcelize' } android { @@ -59,6 +60,7 @@ android { excludes += '/META-INF/{AL2.0,LGPL2.1}' } } + namespace 'com.example.feedcompose' } dependencies { diff --git a/CanonicalLayouts/feed-compose/app/src/main/AndroidManifest.xml b/CanonicalLayouts/feed-compose/app/src/main/AndroidManifest.xml index 245d335ef..9f0ea582d 100644 --- a/CanonicalLayouts/feed-compose/app/src/main/AndroidManifest.xml +++ b/CanonicalLayouts/feed-compose/app/src/main/AndroidManifest.xml @@ -16,8 +16,7 @@ --> + xmlns:tools="http://schemas.android.com/tools"> diff --git a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/MainActivity.kt b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/MainActivity.kt index dac7c4e80..6eda0b885 100644 --- a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/MainActivity.kt +++ b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/MainActivity.kt @@ -29,6 +29,9 @@ import com.example.feedcompose.ui.FeedSampleApp import com.example.feedcompose.ui.theme.FeedComposeTheme class MainActivity : ComponentActivity() { + + private val hasHardwareKey = false + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/ContextMenu.kt b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/ContextMenu.kt new file mode 100644 index 000000000..d277203ba --- /dev/null +++ b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/ContextMenu.kt @@ -0,0 +1,50 @@ +/* + * 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 com.example.feedcompose.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.offset +import androidx.compose.material3.DropdownMenu +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp + +@Composable +internal fun ContextMenu( + modifier: Modifier = Modifier, + offset: DpOffset = DpOffset(0.dp, 0.dp), + content: @Composable ColumnScope.() -> Unit = {} +) { + var isExpanded by remember { mutableStateOf(true) } + if (isExpanded) { + Box(modifier = Modifier.offset(x = offset.x, y = offset.y)) { + DropdownMenu( + expanded = isExpanded, + onDismissRequest = { isExpanded = false }, + modifier = modifier + ) { + content() + } + } + } +} diff --git a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/Indication.kt b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/Indication.kt new file mode 100644 index 000000000..1c27a28cd --- /dev/null +++ b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/Indication.kt @@ -0,0 +1,127 @@ +/* + * 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 com.example.feedcompose.ui.components + +import androidx.compose.foundation.Indication +import androidx.compose.foundation.IndicationInstance +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp + +class OutlinedFocusIndication( + private val shape: Shape, + private val outlineWidth: Dp, + private val outlineColor: Color +) : Indication { + + @Composable + override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance { + val isEnabledState = interactionSource.collectIsFocusedAsState() + + return remember(interactionSource) { + OutlineIndicationInstance( + shape = shape, + outlineWidth = outlineWidth, + outlineColor = outlineColor, + isEnabledState = isEnabledState + ) + } + } +} + +private class OutlineIndicationInstance( + private val shape: Shape, + private val outlineWidth: Dp, + private val outlineColor: Color, + isEnabledState: State +) : IndicationInstance { + private val isEnabled by isEnabledState + + override fun ContentDrawScope.drawIndication() { + drawContent() + if (isEnabled) { + drawOutline( + outline = shape.createOutline( + size = size, + layoutDirection = layoutDirection, + density = this + ), + brush = SolidColor(outlineColor), + style = Stroke(width = outlineWidth.toPx()) + ) + } + } +} + +class HighlightIndication( + private val highlightColor: Color = Color.White, + private val alpha: Float = 0.2f, + private val isEnabled: (isFocused: Boolean, isHovered: Boolean) -> Boolean = { isFocused, isHovered -> + isFocused || isHovered + } +) : Indication { + + @Composable + override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance { + val isFocusedState = interactionSource.collectIsFocusedAsState() + val isHoveredState = interactionSource.collectIsHoveredAsState() + return remember(interactionSource) { + HighlightIndicationInstance( + isFocusedState = isFocusedState, + isHoveredState = isHoveredState, + isEnabled = isEnabled, + highlightColor = highlightColor, + alpha = alpha + ) + } + } +} + +private class HighlightIndicationInstance( + val highlightColor: Color = Color.White, + val alpha: Float = 0.2f, + isFocusedState: State, + isHoveredState: State, + val isEnabled: (isFocused: Boolean, isHovered: Boolean) -> Boolean = { isFocused, isHovered -> + isFocused || isHovered + } +) : IndicationInstance { + private val isFocused by isFocusedState + private val isHovered by isHoveredState + + override fun ContentDrawScope.drawIndication() { + drawContent() + if (isEnabled(isFocused, isHovered)) { + drawRect( + size = size, + color = highlightColor, + alpha = alpha + ) + } + } +} diff --git a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/RightClickable.kt b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/RightClickable.kt new file mode 100644 index 000000000..1b96b486c --- /dev/null +++ b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/RightClickable.kt @@ -0,0 +1,33 @@ +/* + * 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 com.example.feedcompose.ui.components + +import android.view.MotionEvent +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInteropFilter + +@OptIn(ExperimentalComposeUiApi::class) +fun Modifier.rightClickable(onRightClick: (x: Float, y: Float) -> Unit): Modifier = + this.pointerInteropFilter { + if (MotionEvent.BUTTON_SECONDARY == it.buttonState) { + onRightClick(it.x, it.y) + true + } else { + false + } + } diff --git a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/SweetsCard.kt b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/SweetsCard.kt new file mode 100644 index 000000000..912a6e1c1 --- /dev/null +++ b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/SweetsCard.kt @@ -0,0 +1,149 @@ +/* + * 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 com.example.feedcompose.ui.components + +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIconDefaults +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.example.feedcompose.R +import com.example.feedcompose.data.Sweets + +@Composable +internal fun SquareSweetsCard( + sweets: Sweets, + modifier: Modifier = Modifier, + onClick: (Sweets) -> Unit = {} +) { + SweetsCard( + sweets = sweets, + modifier = modifier.aspectRatio(1.0f), + onClick = onClick + ) +} + +@Composable +internal fun PortraitSweetsCard( + sweets: Sweets, + modifier: Modifier = Modifier, + onClick: (Sweets) -> Unit = {} +) { + SweetsCard( + sweets = sweets, + modifier = modifier.aspectRatio(0.707f), + onClick = onClick + ) +} + +@OptIn( + ExperimentalComposeUiApi::class, + ExperimentalMaterial3Api::class +) +@Composable +internal fun SweetsCard( + sweets: Sweets, + modifier: Modifier = Modifier, + onClick: (Sweets) -> Unit = {} +) { + val interactionSource = remember { + MutableInteractionSource() + } + val outlineColor = MaterialTheme.colorScheme.outline + val focusIndication = remember { + OutlinedFocusIndication( + shape = RoundedCornerShape(8.dp), + outlineWidth = 5.dp, + outlineColor = outlineColor + ) + } + val highlightColor = MaterialTheme.colorScheme.onPrimary + val hoverIndication = remember { + HighlightIndication(highlightColor = highlightColor, alpha = 0.4f) { _, isHovered -> + isHovered + } + } + + val optionMenuState: MutableState = remember { + mutableStateOf(null) + } + val localDensity = LocalDensity.current + + Card( + modifier = modifier + .hoverable(interactionSource, true) + .indication(interactionSource, focusIndication) + .indication(interactionSource, hoverIndication) + .pointerHoverIcon(PointerIconDefaults.Hand) + .rightClickable { x, y -> + optionMenuState.value = with(localDensity) { + DpOffset(x.toDp(), y.toDp()) + } + }, + onClick = { onClick(sweets) }, + interactionSource = interactionSource + ) { + optionMenuState.value?.let { + OptionMenu(offset = it) { + onClick(sweets) + } + } + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = sweets.imageUrl, + contentDescription = stringResource(id = R.string.thumbnail_content_description), + placeholder = painterResource(id = R.drawable.placeholder_sweets), + contentScale = ContentScale.Crop + ) + } +} + +@Composable +private fun OptionMenu( + modifier: Modifier = Modifier, + offset: DpOffset = DpOffset(x = 0.dp, y = 0.dp), + onMenuItemSelected: () -> Unit = {} +) { + ContextMenu(offset = offset, modifier = modifier) { + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.show_details)) }, + onClick = { onMenuItemSelected() } + ) + } +} diff --git a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/TextInput.kt b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/TextInput.kt new file mode 100644 index 000000000..ad819e5f8 --- /dev/null +++ b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/TextInput.kt @@ -0,0 +1,104 @@ +/* + * 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 com.example.feedcompose.ui.components + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerIconDefaults +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.TextFieldValue + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +internal fun TextInput( + placeholderText: String = "", + modifier: Modifier = Modifier, + leadingIcon: (@Composable () -> Unit)? = null, + trailingIcon: (@Composable () -> Unit)? = null, + keyboardActions: KeyboardActions = KeyboardActions(), + keyboardOptions: KeyboardOptions = KeyboardOptions(), + onValueChanged: (String) -> Unit = {} +) { + var value by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + var isFocused by rememberSaveable { mutableStateOf(false) } + + if (isFocused) { + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } + + TextField( + value = value, + modifier = modifier + .focusRequester(focusRequester) + .onFocusEvent { isFocused = it.isFocused } + .pointerHoverIcon(PointerIconDefaults.Text) + .onPreviewKeyEvent { + if (KeyEventType.KeyDown == it.type) { + when (it.key) { + Key.Tab -> { + if (it.isShiftPressed) { + focusManager.moveFocus(FocusDirection.Previous) + } else { + focusManager.moveFocus(FocusDirection.Next) + } + true + } + } + false + } else { + false + } + }, + placeholder = { Text(text = placeholderText) }, + onValueChange = { + value = it + onValueChanged(it.text) + }, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + keyboardActions = keyboardActions, + keyboardOptions = keyboardOptions + + ) +} diff --git a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/TopAppBar.kt b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/TopAppBar.kt index 014b94fb5..76e59ad1f 100644 --- a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/TopAppBar.kt +++ b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/TopAppBar.kt @@ -16,28 +16,58 @@ package com.example.feedcompose.ui.components +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.SmallTopAppBar +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIconDefaults +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.example.feedcompose.R +import androidx.compose.material3.TopAppBar as M3TopAppBar @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun TopAppBar(onBackPressed: () -> Unit = {}) { - SmallTopAppBar( + M3TopAppBar( title = { Text(text = stringResource(id = R.string.app_name)) }, navigationIcon = { BackButton(onBackPressed) } ) } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun BackButton(onBackPressed: () -> Unit) { - IconButton(onClick = onBackPressed) { + val interactionSource = remember { MutableInteractionSource() } + + val isFocused by interactionSource.collectIsFocusedAsState() + val isHovered by interactionSource.collectIsHoveredAsState() + val backgroundColor = if (isFocused || isHovered) { + MaterialTheme.colorScheme.outlineVariant + } else { + Color.Transparent + } + + IconButton( + onClick = onBackPressed, + modifier = Modifier + .pointerHoverIcon(PointerIconDefaults.Hand) + .background(backgroundColor, CircleShape), + interactionSource = interactionSource + ) { Icon( painter = painterResource(id = R.drawable.ic_baseline_arrow_back_24), contentDescription = null diff --git a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/feed/Feed.kt b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/feed/Feed.kt index 7e8d69bab..6dbba6f66 100644 --- a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/feed/Feed.kt +++ b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/feed/Feed.kt @@ -25,7 +25,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridItemScope import androidx.compose.foundation.lazy.grid.LazyGridItemSpanScope @@ -40,7 +40,7 @@ import androidx.compose.ui.unit.dp @Composable fun Feed( modifier: Modifier = Modifier, - columns: GridCells = GridCells.Fixed(1), + columns: FeedGridCells = FeedGridCells.Fixed(1), state: LazyGridState = rememberLazyGridState(), contentPadding: PaddingValues = PaddingValues(0.dp), verticalArrangement: Arrangement.Vertical = Arrangement.Top, @@ -48,9 +48,9 @@ fun Feed( flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), content: @ExtensionFunctionType FeedScope.() -> Unit ) { - val feedScope = FeedScopeImpl().apply(content) + val feedScope = FeedScopeImpl(columns = columns).apply(content) LazyVerticalGrid( - columns = columns, + columns = columns.toGridCells(), modifier = modifier, state = state, contentPadding = contentPadding, @@ -71,6 +71,8 @@ fun Feed( } interface FeedScope { + val columns: FeedGridCells + fun item( key: Any? = null, span: (@ExtensionFunctionType LazyGridItemSpanScope.() -> GridItemSpan)? = null, @@ -179,7 +181,7 @@ inline fun FeedScope.itemsIndexed( itemContent(index, items[index]) } -inline fun FeedScope.row( +inline fun FeedScope.section( key: Any? = null, contentType: Any? = null, crossinline content: @Composable LazyGridItemScope.() -> Unit @@ -191,12 +193,11 @@ inline fun FeedScope.row( content() } -@OptIn(ExperimentalFoundationApi::class) inline fun FeedScope.title( key: Any? = null, contentType: Any? = null, crossinline content: @Composable LazyGridItemScope.() -> Unit -) = row(key = key, contentType = contentType) { content() } +) = section(key = key, contentType = contentType) { content() } @OptIn(ExperimentalFoundationApi::class) inline fun FeedScope.action( @@ -204,12 +205,13 @@ inline fun FeedScope.action( contentType: Any? = null, horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, crossinline content: @Composable RowScope.() -> Unit -) = row( +) = section( key = key, contentType = contentType ) { Row( modifier = Modifier + .fillMaxWidth() .focusGroup() .horizontalScroll(rememberScrollState()), horizontalArrangement = horizontalArrangement diff --git a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/feed/FeedGridCells.kt b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/feed/FeedGridCells.kt new file mode 100644 index 000000000..1a0397c13 --- /dev/null +++ b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/feed/FeedGridCells.kt @@ -0,0 +1,33 @@ +/* + * 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 com.example.feedcompose.ui.components.feed + +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.ui.unit.Dp + +interface FeedGridCells { + + fun toGridCells(): GridCells + + class Fixed(private val count: Int) : FeedGridCells { + override fun toGridCells(): GridCells = GridCells.Fixed(count) + } + + class Adaptive(private val minSize: Dp) : FeedGridCells { + override fun toGridCells(): GridCells = GridCells.Adaptive(minSize) + } +} diff --git a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/feed/FeedScopeImpl.kt b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/feed/FeedScopeImpl.kt index 16ab1e32b..a15cecaed 100644 --- a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/feed/FeedScopeImpl.kt +++ b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/components/feed/FeedScopeImpl.kt @@ -23,7 +23,7 @@ import androidx.compose.foundation.lazy.grid.LazyGridItemSpanScope import androidx.compose.runtime.Composable @OptIn(ExperimentalFoundationApi::class) -internal class FeedScopeImpl : FeedScope { +internal class FeedScopeImpl(override val columns: FeedGridCells) : FeedScope { val items = mutableListOf() override fun item( diff --git a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/screen/SweetsDetails.kt b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/screen/SweetsDetails.kt index b4871ddfe..78ef43842 100644 --- a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/screen/SweetsDetails.kt +++ b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/screen/SweetsDetails.kt @@ -17,28 +17,48 @@ package com.example.feedcompose.ui.screen import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.rememberScrollableState +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.gestures.transformable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.pointer.PointerIconDefaults +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.example.feedcompose.R import com.example.feedcompose.data.Sweets import com.example.feedcompose.ui.components.TopAppBar +import kotlin.math.roundToInt @Composable fun SweetsDetails( @@ -91,7 +111,7 @@ private fun SweetsDetailsVertical( .fillMaxWidth(), contentScale = ContentScale.Crop ) - Text( + ZoomableText( text = stringResource(id = sweets.description), modifier = Modifier.padding(32.dp) ) @@ -116,7 +136,7 @@ private fun SweetsDetailsHorizontal( .fillMaxSize() .weight(1.0f) ) - Text( + ZoomableText( text = stringResource(id = sweets.description), modifier = Modifier .padding(start = 32.dp, end = 32.dp, bottom = 32.dp) @@ -126,3 +146,55 @@ private fun SweetsDetailsHorizontal( } } } + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun ZoomableText( + text: String, + modifier: Modifier = Modifier +) { + var scale by remember { mutableStateOf(1.0f) } + var offset by remember { mutableStateOf(Offset.Zero) } + + val transformableState = rememberTransformableState { zoomChange, panChange, _ -> + scale *= zoomChange + offset += panChange + } + + var isModifyKeyPressed by remember { mutableStateOf(false) } + val scrollableState = rememberScrollableState { delta -> + if (isModifyKeyPressed) { + scale *= 1 + delta.coerceIn(-10f, 10f) / 100 + 0f + } else { + delta + } + } + + SelectableText( + text = text, + modifier = modifier + .onPreviewKeyEvent { + isModifyKeyPressed = it.isCtrlPressed + false + } + .scrollable(orientation = Orientation.Vertical, state = scrollableState) + .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) } + .transformable(state = transformableState) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + ) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun SelectableText(text: String, modifier: Modifier = Modifier) { + SelectionContainer(modifier = modifier) { + Text( + text = text, + modifier = Modifier.pointerHoverIcon(PointerIconDefaults.Text) + ) + } +} diff --git a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/screen/SweetsFeed.kt b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/screen/SweetsFeed.kt index ca22f5308..4badb7a8c 100644 --- a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/screen/SweetsFeed.kt +++ b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/screen/SweetsFeed.kt @@ -16,21 +16,23 @@ package com.example.feedcompose.ui.screen -import androidx.compose.foundation.border +import android.os.Parcelable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button -import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -42,32 +44,40 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIconDefaults +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage import com.example.feedcompose.R import com.example.feedcompose.data.Category import com.example.feedcompose.data.DataProvider import com.example.feedcompose.data.DataProvider.chocolates import com.example.feedcompose.data.Sweets +import com.example.feedcompose.ui.components.PortraitSweetsCard +import com.example.feedcompose.ui.components.SquareSweetsCard +import com.example.feedcompose.ui.components.TextInput import com.example.feedcompose.ui.components.feed.Feed +import com.example.feedcompose.ui.components.feed.FeedGridCells import com.example.feedcompose.ui.components.feed.action import com.example.feedcompose.ui.components.feed.footer import com.example.feedcompose.ui.components.feed.items -import com.example.feedcompose.ui.components.feed.row +import com.example.feedcompose.ui.components.feed.section import com.example.feedcompose.ui.components.feed.title import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize @Composable internal fun SweetsFeed(windowSizeClass: WindowSizeClass, onSweetsSelected: (Sweets) -> Unit = {}) { - val selectedFilter: MutableState = remember { + val selectedFilter: MutableState = rememberSaveable { mutableStateOf(Filter.All) } val sweets = DataProvider.sweets.filter { selectedFilter.value.apply(it) } @@ -86,13 +96,29 @@ internal fun SweetsFeed(windowSizeClass: WindowSizeClass, onSweetsSelected: (Swe title(contentType = "feed-title") { FeedTitle(text = stringResource(id = R.string.app_name)) } - items(DataProvider.misc, contentType = { "sweets" }, key = { it.id }) { - SquareSweetsCard(sweets = it, onClick = onSweetsSelected) + action( + contentType = "filter-selector", + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterSelector(selectedFilter = selectedFilter.value) { selectedFilter.value = it } + if (windowSizeClass.widthSizeClass != WindowWidthSizeClass.Compact) { + Spacer(modifier = Modifier.weight(1f)) + SearchTextInput(modifier = Modifier.defaultMinSize(minWidth = 400.dp)) + } + } + if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact) { + action { SearchTextInput(modifier = Modifier.weight(1f)) } + } + items(sweets, contentType = { "sweets" }, key = { it.id }) { + SquareSweetsCard( + sweets = it, + onClick = onSweetsSelected + ) } title(contentType = "section-title") { SectionTitle(text = stringResource(id = R.string.chocolate)) } - row(contentType = "chocolate-list") { + section(contentType = "chocolate-list") { HorizontalSweetsList( sweets = chocolates, cardWidth = if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded) { @@ -103,16 +129,7 @@ internal fun SweetsFeed(windowSizeClass: WindowSizeClass, onSweetsSelected: (Swe onSweetsSelected = onSweetsSelected ) } - title(contentType = "section-title") { - SectionTitle(text = stringResource(id = R.string.candy_or_pastry)) - } - action( - contentType = "filter-selector", - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FilterSelector(selectedFilter = selectedFilter.value) { selectedFilter.value = it } - } - items(sweets, contentType = { "sweets" }, key = { it.id }) { + items(DataProvider.misc, contentType = { "sweets" }, key = { it.id }) { SquareSweetsCard(sweets = it, onClick = onSweetsSelected) } footer { @@ -128,9 +145,9 @@ internal fun SweetsFeed(windowSizeClass: WindowSizeClass, onSweetsSelected: (Swe @Composable private fun rememberColumns(windowSizeClass: WindowSizeClass) = remember(windowSizeClass) { when (windowSizeClass.widthSizeClass) { - WindowWidthSizeClass.Compact -> GridCells.Fixed(1) - WindowWidthSizeClass.Medium -> GridCells.Fixed(2) - else -> GridCells.Adaptive(240.dp) + WindowWidthSizeClass.Compact -> FeedGridCells.Fixed(1) + WindowWidthSizeClass.Medium -> FeedGridCells.Fixed(2) + else -> FeedGridCells.Adaptive(240.dp) } } @@ -172,7 +189,7 @@ private fun HorizontalSweetsList( } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable private fun FilterSelector(selectedFilter: Filter, onFilterSelected: (Filter) -> Unit) { val filters = listOf( @@ -182,8 +199,20 @@ private fun FilterSelector(selectedFilter: Filter, onFilterSelected: (Filter) -> ) filters.forEach { (filter, labelId) -> val selected = selectedFilter == filter + val interactionSource = remember { + MutableInteractionSource() + } + val isFocused by interactionSource.collectIsFocusedAsState() + val borderColor = if (isFocused) { + MaterialTheme.colorScheme.outline + } else { + Color.Transparent + } FilterChip( + modifier = Modifier + .pointerHoverIcon(PointerIconDefaults.Hand), selected = selected, + border = FilterChipDefaults.filterChipBorder(borderColor = borderColor), onClick = { onFilterSelected(filter) }, label = { Text(text = stringResource(id = labelId)) }, leadingIcon = { @@ -193,87 +222,48 @@ private fun FilterSelector(selectedFilter: Filter, onFilterSelected: (Filter) -> contentDescription = null ) } - } + }, + interactionSource = interactionSource ) } } +@Parcelize +sealed class Filter(private val categories: List) : Parcelable { + fun apply(sweets: Sweets): Boolean = categories.indexOf(sweets.category) != -1 + + object All : Filter(listOf(Category.Candy, Category.Pastry)) + + object Candy : Filter(listOf(Category.Candy)) + + object Pastry : Filter(listOf(Category.Pastry)) +} + +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun BackToTopButton(modifier: Modifier = Modifier, onClick: () -> Unit = {}) { Box(contentAlignment = Alignment.Center) { - Button(onClick = onClick, modifier = modifier) { + Button(onClick = onClick, modifier = modifier.pointerHoverIcon(PointerIconDefaults.Hand)) { Icon(painter = painterResource(id = R.drawable.ic_baseline_arrow_upward_24), null) - Text(text = "Back to top") + Text(text = stringResource(id = R.string.back_to_top)) } } } @Composable -private fun SquareSweetsCard( - sweets: Sweets, - modifier: Modifier = Modifier, - onClick: (Sweets) -> Unit = {} -) { - SweetsCard( - sweets = sweets, - modifier = modifier.aspectRatio(1.0f), - onClick = onClick - ) -} - -@Composable -private fun PortraitSweetsCard( - sweets: Sweets, - modifier: Modifier = Modifier, - onClick: (Sweets) -> Unit = {} -) { - SweetsCard( - sweets = sweets, - modifier = modifier.aspectRatio(0.707f), - onClick = onClick - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SweetsCard( - sweets: Sweets, - modifier: Modifier = Modifier, - onClick: (Sweets) -> Unit = {} -) { - var isFocused by remember { - mutableStateOf(false) - } - val outlineColor = if (isFocused) { - MaterialTheme.colorScheme.outline - } else { - MaterialTheme.colorScheme.background - } - - Card( - modifier = modifier - .onFocusChanged { - isFocused = it.isFocused - } - .border(width = 2.dp, color = outlineColor), - onClick = { onClick(sweets) } - ) { - AsyncImage( - modifier = Modifier.fillMaxSize(), - model = sweets.imageUrl, - contentDescription = stringResource(id = R.string.thumbnail_content_description), - placeholder = painterResource(id = R.drawable.placeholder_sweets), - contentScale = ContentScale.Crop +private fun SearchTextInput(modifier: Modifier = Modifier) { + TextInput( + placeholderText = stringResource(id = R.string.search_text), + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_search_24), + contentDescription = stringResource(id = R.string.search) + ) + }, + modifier = modifier, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Go ) - } -} - -sealed class Filter(private val categories: List) { - fun apply(sweets: Sweets): Boolean = categories.indexOf(sweets.category) != -1 - - object All : Filter(listOf(Category.Candy, Category.Pastry)) - - object Candy : Filter(listOf(Category.Candy)) - - object Pastry : Filter(listOf(Category.Pastry)) + ) } diff --git a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/theme/FeedComposeTheme.kt b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/theme/FeedComposeTheme.kt index 4b129ee48..4b5bf143d 100644 --- a/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/theme/FeedComposeTheme.kt +++ b/CanonicalLayouts/feed-compose/app/src/main/java/com/example/feedcompose/ui/theme/FeedComposeTheme.kt @@ -29,7 +29,7 @@ import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView -import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat private val DarkColorScheme = darkColorScheme( primary = Purple80, @@ -41,16 +41,6 @@ private val LightColorScheme = lightColorScheme( primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ ) @Composable @@ -72,7 +62,7 @@ fun FeedComposeTheme( if (!view.isInEditMode) { SideEffect { (view.context as Activity).window.statusBarColor = colorScheme.primary.toArgb() - ViewCompat.getWindowInsetsController(view)?.isAppearanceLightStatusBars = darkTheme + WindowCompat.getInsetsController((view.context as Activity).window, view)?.isAppearanceLightStatusBars = darkTheme } } diff --git a/CanonicalLayouts/feed-compose/app/src/main/res/drawable/ic_baseline_info_24.xml b/CanonicalLayouts/feed-compose/app/src/main/res/drawable/ic_baseline_info_24.xml new file mode 100644 index 000000000..e0ecb4046 --- /dev/null +++ b/CanonicalLayouts/feed-compose/app/src/main/res/drawable/ic_baseline_info_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/CanonicalLayouts/feed-compose/app/src/main/res/drawable/ic_baseline_search_24.xml b/CanonicalLayouts/feed-compose/app/src/main/res/drawable/ic_baseline_search_24.xml new file mode 100644 index 000000000..a5687c639 --- /dev/null +++ b/CanonicalLayouts/feed-compose/app/src/main/res/drawable/ic_baseline_search_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/CanonicalLayouts/feed-compose/app/src/main/res/values/strings.xml b/CanonicalLayouts/feed-compose/app/src/main/res/values/strings.xml index 3bc52945a..cc3144b12 100644 --- a/CanonicalLayouts/feed-compose/app/src/main/res/values/strings.xml +++ b/CanonicalLayouts/feed-compose/app/src/main/res/values/strings.xml @@ -15,7 +15,7 @@ --> - Compose-based Feed Sample + Sweets feed Sweets @@ -28,6 +28,12 @@ A sweets + Show details + + Search text + Search + Back to top + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam in scelerisque sem. Mauris volutpat, dolor id interdum ullamcorper, risus dolor egestas lectus, sit amet mattis purus