Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
<a href="https://github.com/Gurupreet/ComposeCookBook/releases/download/latest-master/app-debug.apk" >
<img src = "https://img.shields.io/badge/Master-Master?color=7885FF&label=Sample%20App&logo=android&style=for-the-badge" />
</a>
<a href = "https://developer.android.com/jetpack/androidx/versions/all-channel#december_16_2020">
<img src = "https://img.shields.io/badge/Jetpack%20Compose-1.0.1-brightgreen" />
<a href = "https://developer.android.com/develop/ui/compose/bom/bom-mapping">
<img src = "https://img.shields.io/badge/Jetpack%20Compose-BOM%202024.12.01-brightgreen" />
</a>
<a href = "https://github.com/Gurupreet/ComposeCookBook/actions/workflows/android.yml">
<img src = "https://github.com/Gurupreet/ComposeCookBook/actions/workflows/android.yml/badge.svg" />
Expand Down Expand Up @@ -102,7 +102,6 @@ Please get **Android Studio Bumblebee latest Canary** [from here](https://develo

## Coming Soon
- Some of the features that will be available in coming weeks
- Advance lists: Pull Refresh, Swipe lists etc
- Clean Architecture Sample with coroutines.
- Advance canvas drawing.
Much more in pipeline stay tuned!!
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.guru.composecookbook.ui.home.advancelists

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onParent
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.test.swipeRight
import org.junit.Rule
import org.junit.Test

class SwipeableListsTest {

@get:Rule val composeTestRule = createComposeRule()

@Test
fun albumsAreListedOnFirstComposition() {
composeTestRule.setContent { SwipeableLists() }

composeTestRule.onNodeWithText("Perfect").assertIsDisplayed()
composeTestRule.onNodeWithText("Havana").assertIsDisplayed()
// Two albums in the sample data are by Ed Sheeran, so the artist alone is not unique.
composeTestRule.onAllNodesWithText("Ed Sheeran").onFirst().assertIsDisplayed()
}

@Test
fun everyRowExposesItsOverflowButtonToAccessibilityServices() {
composeTestRule.setContent { SwipeableLists() }

composeTestRule.onAllNodesWithContentDescription("More options").onFirst().assertIsDisplayed()
}

@Test
fun swipingEndToStartDeletesTheRowAndOffersUndo() {
composeTestRule.setContent { SwipeableLists() }

// The swipe must be dispatched to the row, not to the song title: the title node is only a
// few pixels wide, well short of the box's 50% positional threshold.
composeTestRule.onNodeWithText("Perfect").onParent().performTouchInput { swipeLeft() }
composeTestRule.waitForIdle()

composeTestRule.onNodeWithText("Perfect").assertDoesNotExist()
composeTestRule.onNodeWithText("Perfect deleted").assertIsDisplayed()
composeTestRule.onNodeWithText("Undo").assertIsDisplayed()
}

@Test
fun swipingStartToEndArchivesTheRowAndOffersUndo() {
composeTestRule.setContent { SwipeableLists() }

composeTestRule.onNodeWithText("Perfect").onParent().performTouchInput { swipeRight() }
composeTestRule.waitForIdle()

composeTestRule.onNodeWithText("Perfect").assertDoesNotExist()
composeTestRule.onNodeWithText("Perfect archived").assertIsDisplayed()
}

@Test
fun undoRestoresASwipedAwayRow() {
composeTestRule.setContent { SwipeableLists() }

composeTestRule.onNodeWithText("Perfect").onParent().performTouchInput { swipeLeft() }
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Undo").performClick()
composeTestRule.waitForIdle()

composeTestRule.onNodeWithText("Perfect").assertIsDisplayed()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.guru.composecookbook.ui.home.pullrefreshdemos

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test

class PullRefreshListTest {

@get:Rule val composeTestRule = createComposeRule()

@Test
fun firstAlbumIsDisplayedInitially() {
composeTestRule.setContent { PullRefreshList(onPullRefresh = {}) }

composeTestRule.onNodeWithText("Perfect").assertIsDisplayed()
// Two albums in the sample data are by Ed Sheeran, so this row text is not unique.
composeTestRule
.onAllNodesWithText("Ed Sheeran, Album by Ed Sheeran-2016")
.onFirst()
.assertIsDisplayed()
}

@Test
fun refreshedAlbumIsAbsentBeforeAnyRefresh() {
composeTestRule.setContent { PullRefreshList(onPullRefresh = {}) }

// AlbumsDataProvider.album is only prepended by a refresh, so it must not be there yet.
// Match on the artist/description line: it is the one string unique to that album.
composeTestRule.onNodeWithText("Adele, Album by Adele-2016").assertDoesNotExist()
}

@Test
fun pullingDownInvokesTheRefreshCallback() {
var invocations = 0
composeTestRule.setContent { PullRefreshList(onPullRefresh = { invocations++ }) }

// The gesture has to travel far enough to cross PullToRefreshBox's threshold, so it is
// dispatched to the whole screen rather than to a single row.
composeTestRule.onRoot().performTouchInput { swipeDown() }
composeTestRule.waitForIdle()

assertEquals(1, invocations)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
Expand Down Expand Up @@ -39,7 +39,7 @@ fun AnimationScreen() {
Scaffold(
modifier = Modifier.testTag(TestTags.ANIM_SCREEN_ROOT),
topBar = {
SmallTopAppBar(
TopAppBar(
title = { Text(text = "Animations") },
navigationIcon = {
IconButton(onClick = { animateIcon = !animateIcon }) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
Expand Down Expand Up @@ -49,7 +49,7 @@ class AdvanceListsActivity : ComponentActivity() {
// A surface container using the 'background' color from the theme
Scaffold(
topBar = {
SmallTopAppBar(
TopAppBar(
title = { Text(text = "Advance Lists(In Progress)") },
navigationIcon = {
IconButton(onClick = { onBackPressed() }) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.rememberImagePainter
import coil3.compose.rememberAsyncImagePainter
import com.guru.composecookbook.data.DemoDataProvider
import com.guru.composecookbook.data.model.Tweet
import com.guru.composecookbook.theme.typography
Expand Down Expand Up @@ -145,8 +145,8 @@ fun AnimatedListItem(tweet: Tweet, itemIndex: Int, animationIndex: Int) {
Row(modifier = animatedModifier, verticalAlignment = Alignment.CenterVertically) {
Image(
painter =
rememberImagePainter(
data =
rememberAsyncImagePainter(
model =
"https://picsum.photos/id/${
itemIndex +
1
Expand Down
Original file line number Diff line number Diff line change
@@ -1,71 +1,129 @@
package com.guru.composecookbook.ui.home.advancelists

import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.guru.composecookbook.data.AlbumsDataProvider
import com.guru.composecookbook.data.model.Album
import kotlinx.coroutines.launch

/**
* Demonstrates Material3 [SwipeToDismissBox]:
* - swipe left (end to start) to delete an item,
* - swipe right (start to end) to archive it,
* - either action removes the item and offers Undo via a [Snackbar].
*/
@Composable
fun SwipeableLists() {
val albums by remember { mutableStateOf(AlbumsDataProvider.albums) }
LazyColumn {
itemsIndexed(
items = albums,
itemContent = { index, album -> SwipeableListItem(index, album) { index -> } },
)
val albums = remember { AlbumsDataProvider.albums.toMutableStateList() }
val snackbarHostState = remember { SnackbarHostState() }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()

Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(state = listState) {
items(items = albums, key = { it.id }) { album ->
SwipeableListItem(
album = album,
onItemSwiped = { dismissValue ->
val index = albums.indexOf(album)
if (index >= 0) {
albums.removeAt(index)
val action =
if (dismissValue == SwipeToDismissBoxValue.StartToEnd) "archived" else "deleted"
scope.launch {
val result =
snackbarHostState.showSnackbar(
message = "${album.song} $action",
actionLabel = "Undo",
duration = SnackbarDuration.Short,
)
if (result == SnackbarResult.ActionPerformed) {
val restoreAt = index.coerceAtMost(albums.size)
albums.add(restoreAt, album)
// LazyColumn keeps the first visible item anchored, so a row re-inserted above
// it would silently land off-screen. Scroll it back into view.
listState.animateScrollToItem(restoreAt)
}
}
}
},
)
}
}
SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter))
}
}

@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable
fun SwipeableListItem(index: Int, album: Album, onItemSwiped: (Int) -> Unit) {
fun SwipeableListItem(album: Album, onItemSwiped: (SwipeToDismissBoxValue) -> Unit) {
val dismissState =
rememberSwipeToDismissBoxState(
confirmValueChange = { dismissValue ->
if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
onItemSwiped(index)
if (dismissValue != SwipeToDismissBoxValue.Settled) {
onItemSwiped(dismissValue)
true
} else {
false
}
}
},
// require half of the item width to be dragged before dismissing
positionalThreshold = { totalDistance -> totalDistance * 0.5f },
)

SwipeToDismissBox(
state = dismissState,
backgroundContent = {
Box(
modifier =
Modifier.fillMaxSize()
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.CenterEnd,
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.onErrorContainer,
)
}
},
backgroundContent = { SwipeActionBackground(dismissState.dismissDirection) },
content = { ListItemContent(album) },
enableDismissFromStartToEnd = false,
enableDismissFromStartToEnd = true,
enableDismissFromEndToStart = true,
)
}

@Composable
private fun SwipeActionBackground(direction: SwipeToDismissBoxValue) {
val isArchive = direction == SwipeToDismissBoxValue.StartToEnd
val background =
if (isArchive) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.errorContainer
Box(
modifier =
Modifier.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 8.dp)
.clip(MaterialTheme.shapes.medium)
.background(background)
.padding(horizontal = 16.dp),
contentAlignment = if (isArchive) Alignment.CenterStart else Alignment.CenterEnd,
) {
if (isArchive) {
Icon(
imageVector = Icons.Default.Archive,
contentDescription = "Archive",
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
} else {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}

@Composable
private fun ListItemContent(album: Album) {
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.livedata.observeAsState
Expand Down Expand Up @@ -139,7 +139,7 @@ fun ListViewContent(
*/
Scaffold(
topBar = {
SmallTopAppBar(
TopAppBar(
title = {
Column(modifier = Modifier.padding(4.dp)) {
/*
Expand Down
Loading
Loading