From c2370f7ef9f122f7c62b7b32e7a9c1d81e4c4cc6 Mon Sep 17 00:00:00 2001 From: Manuel Vivo Date: Tue, 31 Mar 2020 13:08:11 +0200 Subject: [PATCH] Adds loading and error screens, and swipe to refresh functionality Change-Id: Ic8f7a6bb5643bd01c0cb5124d3dd6f2bc9b5b912 --- .../data/posts/impl/FakePostsRepository.kt | 22 ++- .../com/example/jetnews/ui/SwipeToRefresh.kt | 160 +++++++++++++++++ .../jetnews/ui/article/ArticleScreen.kt | 2 +- .../example/jetnews/ui/effect/PostsEffects.kt | 2 +- .../com/example/jetnews/ui/home/HomeScreen.kt | 161 ++++++++++++++---- .../jetnews/ui/interests/InterestsScreen.kt | 6 +- .../jetnews/ui/state/RefreshableUiState.kt | 90 ++++++++++ .../example/jetnews/ui/{ => state}/UiState.kt | 2 +- JetNews/build.gradle | 2 +- 9 files changed, 400 insertions(+), 47 deletions(-) create mode 100644 JetNews/app/src/main/java/com/example/jetnews/ui/SwipeToRefresh.kt create mode 100644 JetNews/app/src/main/java/com/example/jetnews/ui/state/RefreshableUiState.kt rename JetNews/app/src/main/java/com/example/jetnews/ui/{ => state}/UiState.kt (98%) diff --git a/JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/FakePostsRepository.kt b/JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/FakePostsRepository.kt index 1639c13946..a5c014122f 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/FakePostsRepository.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/FakePostsRepository.kt @@ -22,11 +22,14 @@ import androidx.ui.graphics.imageFromResource import com.example.jetnews.data.Result import com.example.jetnews.data.posts.PostsRepository import com.example.jetnews.model.Post +import java.lang.IllegalStateException import java.util.concurrent.ExecutorService +import kotlin.random.Random /** * Implementation of PostsRepository that returns a hardcoded list of * posts with resources after some delay in a background thread. + * 1/3 of the times will throw an error. * * The result is posted to the resultThreadHandler passed as a parameter. */ @@ -53,15 +56,21 @@ class FakePostsRepository( override fun getPost(postId: String, callback: (Result) -> Unit) { executeInBackground(callback) { - resultThreadHandler.post { callback(Result.Success( - postsWithResources.find { it.id == postId } - )) } + resultThreadHandler.post { + callback(Result.Success( + postsWithResources.find { it.id == postId } + )) + } } } override fun getPosts(callback: (Result>) -> Unit) { executeInBackground(callback) { simulateNetworkRequest() + Thread.sleep(1500L) + if (shouldRandomlyFail()) { + throw IllegalStateException() + } resultThreadHandler.post { callback(Result.Success(postsWithResources)) } } } @@ -86,8 +95,13 @@ class FakePostsRepository( private var networkRequestDone = false private fun simulateNetworkRequest() { if (!networkRequestDone) { - Thread.sleep(5000) + Thread.sleep(2000L) networkRequestDone = true } } + + /** + * 1/3 requests should fail loading + */ + private fun shouldRandomlyFail(): Boolean = Random.nextFloat() < 0.33f } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/SwipeToRefresh.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/SwipeToRefresh.kt new file mode 100644 index 0000000000..c666498651 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/SwipeToRefresh.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2020 Google, Inc. + * + * 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.jetnews.ui + +import androidx.animation.AnimatedFloat +import androidx.animation.AnimationBuilder +import androidx.animation.AnimationEndReason +import androidx.animation.TweenBuilder +import androidx.compose.Composable +import androidx.compose.onCommit +import androidx.compose.remember +import androidx.compose.state +import androidx.ui.animation.animatedFloat +import androidx.ui.core.Alignment +import androidx.ui.core.DensityAmbient +import androidx.ui.core.Modifier +import androidx.ui.core.PassThroughLayout +import androidx.ui.foundation.Box +import androidx.ui.foundation.animation.AnchorsFlingConfig +import androidx.ui.foundation.animation.fling +import androidx.ui.foundation.gestures.DragDirection +import androidx.ui.foundation.gestures.draggable +import androidx.ui.layout.Stack +import androidx.ui.layout.offset +import androidx.ui.unit.dp +import androidx.ui.unit.px + +private val SWIPE_DISTANCE_SIZE = 100.dp +private const val SWIPE_DOWN_OFFSET = 1.2f + +@Composable +fun SwipeToRefreshLayout( + refreshingState: Boolean, + onRefresh: () -> Unit, + refreshIndicator: @Composable() () -> Unit, + content: @Composable() () -> Unit +) { + val size = with(DensityAmbient.current) { SWIPE_DISTANCE_SIZE.toPx().value } + // min is below negative to hide + val min = -size + val max = size * SWIPE_DOWN_OFFSET + StateDraggable( + state = refreshingState, + onStateChange = { shouldRefresh -> if (shouldRefresh) onRefresh() }, + anchorsToState = listOf(min to false, max to true), + animationBuilder = TweenBuilder(), + dragDirection = DragDirection.Vertical, + minValue = min, + maxValue = max + ) { dragPosition -> + val dpOffset = with(DensityAmbient.current) { + (dragPosition.value * 0.5).px.toDp() + } + Stack { + content() + Box(Modifier.gravity(Alignment.TopCenter).offset(0.dp, dpOffset)) { + if (dragPosition.value != min) { + refreshIndicator() + } + } + } + } +} + +// Copied from ui/ui-material/src/main/java/androidx/ui/material/internal/StateDraggable.kt + +/** + * Higher-level component that allows dragging around anchored positions binded to different states + * + * Example might be a Switch which you can drag between two states (true or false). + * + * Additional features compared to regular [draggable] modifier: + * 1. The AnimatedFloat hosted inside and its value will be in sync with call site state + * 2. When the anchor is reached, [onStateChange] will be called with state mapped to this anchor + * 3. When the anchor is reached and [onStateChange] with corresponding state is called, but + * call site didn't update state to the reached one for some reason, + * this component performs rollback to the previous (correct) state. + * 4. When new [state] is provided, component will be animated to state's anchor + * + * children of this composable will receive [AnimatedFloat] class from which + * they can read current value when they need or manually animate. + * + * @param T type with which state is represented + * @param state current state to represent Float value with + * @param onStateChange callback to update call site's state + * @param anchorsToState pairs of anchors to states to map anchors to state and vise versa + * @param animationBuilder animation which will be used for animations + * @param dragDirection direction in which drag should be happening. + * Either [DragDirection.Vertical] or [DragDirection.Horizontal] + * @param minValue lower bound for draggable value in this component + * @param maxValue upper bound for draggable value in this component + * @param enabled whether or not this Draggable is enabled and should consume events + */ +// TODO(malkov/tianliu) (figure our how to make it better and make public) +@Composable +internal fun StateDraggable( + state: T, + onStateChange: (T) -> Unit, + anchorsToState: List>, + animationBuilder: AnimationBuilder, + dragDirection: DragDirection, + enabled: Boolean = true, + minValue: Float = Float.MIN_VALUE, + maxValue: Float = Float.MAX_VALUE, + content: @Composable() (AnimatedFloat) -> Unit +) { + val forceAnimationCheck = state { true } + + val anchors = remember(anchorsToState) { anchorsToState.map { it.first } } + val currentValue = anchorsToState.firstOrNull { it.second == state }!!.first + val flingConfig = + AnchorsFlingConfig(anchors, animationBuilder, onAnimationEnd = { reason, finalValue, _ -> + if (reason != AnimationEndReason.Interrupted) { + val newState = anchorsToState.firstOrNull { it.first == finalValue }?.second + if (newState != null && newState != state) { + onStateChange(newState) + forceAnimationCheck.value = !forceAnimationCheck.value + } + } + }) + val position = animatedFloat(currentValue) + position.setBounds(minValue, maxValue) + + // This state is to force this component to be recomposed and trigger onCommit below + // This is needed to stay in sync with drag state that caller side holds + onCommit(currentValue, forceAnimationCheck.value) { + position.animateTo(currentValue, animationBuilder) + } + val draggable = draggable( + dragDirection = dragDirection, + onDragDeltaConsumptionRequested = { delta -> + val old = position.value + position.snapTo(position.value + delta) + position.value - old + }, + onDragStopped = { position.fling(flingConfig, it) }, + enabled = enabled, + startDragImmediately = position.isRunning + ) + // TODO(b/150706555): This layout is temporary and should be removed once Semantics + // is implemented with modifiers. + @Suppress("DEPRECATION") + PassThroughLayout(draggable) { + content(position) + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt index dedbd5da6d..23660482eb 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt @@ -53,13 +53,13 @@ import com.example.jetnews.data.successOr import com.example.jetnews.model.Post import com.example.jetnews.ui.Screen import com.example.jetnews.ui.ThemedPreview -import com.example.jetnews.ui.UiState import com.example.jetnews.ui.darkThemeColors import com.example.jetnews.ui.effect.fetchPost import com.example.jetnews.ui.home.BookmarkButton import com.example.jetnews.ui.home.isFavorite import com.example.jetnews.ui.home.toggleBookmark import com.example.jetnews.ui.navigateTo +import com.example.jetnews.ui.state.UiState @Composable fun ArticleScreen(postId: String, postsRepository: PostsRepository) { diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/effect/PostsEffects.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/effect/PostsEffects.kt index 2a8c1a601d..199052220b 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/effect/PostsEffects.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/effect/PostsEffects.kt @@ -22,7 +22,7 @@ import androidx.compose.state import com.example.jetnews.data.Result import com.example.jetnews.data.posts.PostsRepository import com.example.jetnews.model.Post -import com.example.jetnews.ui.UiState +import com.example.jetnews.ui.state.UiState /** * Effect that interacts with the repository to obtain a post with postId to display on the screen. diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreen.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreen.kt index c190d9602a..199f862ef6 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreen.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreen.kt @@ -16,22 +16,30 @@ package com.example.jetnews.ui.home +import android.os.Handler +import android.os.Looper import androidx.compose.Composable +import androidx.compose.onActive import androidx.compose.remember -import androidx.ui.animation.Crossfade +import androidx.compose.stateFor import androidx.ui.core.Alignment import androidx.ui.core.ContextAmbient import androidx.ui.core.Modifier +import androidx.ui.foundation.Box import androidx.ui.foundation.Clickable import androidx.ui.foundation.HorizontalScroller import androidx.ui.foundation.Icon import androidx.ui.foundation.Text import androidx.ui.foundation.VerticalScroller +import androidx.ui.foundation.shape.corner.CircleShape import androidx.ui.layout.Column import androidx.ui.layout.Row +import androidx.ui.layout.Stack import androidx.ui.layout.fillMaxSize import androidx.ui.layout.padding +import androidx.ui.layout.preferredSize import androidx.ui.layout.wrapContentSize +import androidx.ui.material.CircularProgressIndicator import androidx.ui.material.Divider import androidx.ui.material.DrawerState import androidx.ui.material.EmphasisAmbient @@ -40,6 +48,9 @@ import androidx.ui.material.MaterialTheme import androidx.ui.material.ProvideEmphasis import androidx.ui.material.Scaffold import androidx.ui.material.ScaffoldState +import androidx.ui.material.Snackbar +import androidx.ui.material.Surface +import androidx.ui.material.TextButton import androidx.ui.material.TopAppBar import androidx.ui.material.ripple.ripple import androidx.ui.res.vectorResource @@ -48,27 +59,24 @@ import androidx.ui.unit.dp import com.example.jetnews.R import com.example.jetnews.data.posts.PostsRepository import com.example.jetnews.data.posts.impl.PreviewPostsRepository -import com.example.jetnews.data.posts.impl.posts import com.example.jetnews.model.Post import com.example.jetnews.ui.AppDrawer import com.example.jetnews.ui.Screen +import com.example.jetnews.ui.SwipeToRefreshLayout import com.example.jetnews.ui.ThemedPreview -import com.example.jetnews.ui.UiState import com.example.jetnews.ui.darkThemeColors import com.example.jetnews.ui.navigateTo -import com.example.jetnews.ui.previewDataFrom -import com.example.jetnews.ui.uiStateFrom +import com.example.jetnews.ui.state.RefreshableUiState +import com.example.jetnews.ui.state.currentData +import com.example.jetnews.ui.state.loading +import com.example.jetnews.ui.state.previewDataFrom +import com.example.jetnews.ui.state.refreshableUiStateFrom +import com.example.jetnews.ui.state.refreshing @Composable -fun HomeScreen(postsRepository: PostsRepository) { - val postsState = uiStateFrom(postsRepository::getPosts) - HomeScreenScaffold(postsState = postsState) -} - -@Composable -fun HomeScreenScaffold( - scaffoldState: ScaffoldState = remember { ScaffoldState() }, - postsState: UiState> +fun HomeScreen( + postsRepository: PostsRepository, + scaffoldState: ScaffoldState = remember { ScaffoldState() } ) { Scaffold( scaffoldState = scaffoldState, @@ -89,25 +97,65 @@ fun HomeScreenScaffold( ) }, bodyContent = { modifier -> - Crossfade(current = postsState) { uiState -> - when (uiState) { - is UiState.Success -> HomeScreenBody( - modifier = modifier, - posts = (postsState as UiState.Success>).data - ) - is UiState.Loading -> { - Text( - modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.Center), - text = "Loading" - ) - } - else -> { - // Empty - } + HomeScreenContent(postsRepository, modifier) + } + ) +} + +@Composable +private fun HomeScreenContent( + postsRepository: PostsRepository, + modifier: Modifier = Modifier.None +) { + val (postsState, refreshPosts) = refreshableUiStateFrom(postsRepository::getPosts) + + if (postsState.loading && !postsState.refreshing) { + LoadingHomeScreen() + } else { + SwipeToRefreshLayout( + refreshingState = postsState.refreshing, + onRefresh = { refreshPosts() }, + refreshIndicator = { + Surface(elevation = 10.dp, shape = CircleShape) { + CircularProgressIndicator(Modifier.preferredSize(50.dp).padding(4.dp)) } } + ) { + HomeScreenBodyWrapper( + modifier = modifier, + state = postsState, + onErrorAction = { + refreshPosts() + } + ) } - ) + } +} + +@Composable +private fun HomeScreenBodyWrapper( + modifier: Modifier = Modifier.None, + state: RefreshableUiState>, + onErrorAction: () -> Unit +) { + // State for showing the Snackbar error. This state will reset with the content of the lambda + // inside stateFor each time the RefreshableUiState input parameter changes. + // showSnackbarError is the value of the error state, use updateShowSnackbarError to update it. + val (showSnackbarError, updateShowSnackbarError) = stateFor(state) { + state is RefreshableUiState.Error + } + + Stack(modifier = modifier.fillMaxSize()) { + state.currentData?.let { posts -> + HomeScreenBody(posts = posts) + } + ErrorSnackbar( + showError = showSnackbarError, + onErrorAction = onErrorAction, + onDismiss = { updateShowSnackbarError(false) }, + modifier = Modifier.gravity(Alignment.BottomCenter) + ) + } } @Composable @@ -130,6 +178,47 @@ private fun HomeScreenBody( } } +@Composable +private fun LoadingHomeScreen() { + Box(modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.Center)) { + CircularProgressIndicator() + } +} + +@Composable +fun ErrorSnackbar( + showError: Boolean, + modifier: Modifier = Modifier.None, + onErrorAction: () -> Unit = { }, + onDismiss: () -> Unit = { } +) { + if (showError) { + + // Make Snackbar disappear after 5 seconds if the user hasn't interacted with it + onActive { + // With coroutines, this will be cancellable + Handler(Looper.getMainLooper()).postDelayed({ + onDismiss() + }, 5000L) + } + + Snackbar( + modifier = modifier.padding(16.dp), + text = { Text("Can't update latest news") }, + action = { + TextButton( + onClick = { + onErrorAction() + onDismiss() + } + ) { + Text("RETRY") + } + } + ) + } +} + @Composable private fun HomeScreenTopSection(post: Post) { ProvideEmphasis(EmphasisAmbient.current.high) { @@ -207,9 +296,9 @@ fun PreviewHomeScreenBody() { @Composable private fun PreviewDrawerOpen() { ThemedPreview { - HomeScreenScaffold( - scaffoldState = ScaffoldState(drawerState = DrawerState.Opened), - postsState = UiState.Success(posts) + HomeScreen( + postsRepository = PreviewPostsRepository(ContextAmbient.current), + scaffoldState = ScaffoldState(drawerState = DrawerState.Opened) ) } } @@ -227,9 +316,9 @@ fun PreviewHomeScreenBodyDark() { @Composable private fun PreviewDrawerOpenDark() { ThemedPreview(darkThemeColors) { - HomeScreenScaffold( - scaffoldState = ScaffoldState(drawerState = DrawerState.Opened), - postsState = UiState.Success(posts) + HomeScreen( + postsRepository = PreviewPostsRepository(ContextAmbient.current), + scaffoldState = ScaffoldState(drawerState = DrawerState.Opened) ) } } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsScreen.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsScreen.kt index 2eb3b45f01..3e07027fbf 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsScreen.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsScreen.kt @@ -54,10 +54,10 @@ import com.example.jetnews.ui.AppDrawer import com.example.jetnews.ui.JetnewsStatus import com.example.jetnews.ui.Screen import com.example.jetnews.ui.ThemedPreview -import com.example.jetnews.ui.UiState import com.example.jetnews.ui.darkThemeColors -import com.example.jetnews.ui.previewDataFrom -import com.example.jetnews.ui.uiStateFrom +import com.example.jetnews.ui.state.UiState +import com.example.jetnews.ui.state.previewDataFrom +import com.example.jetnews.ui.state.uiStateFrom private enum class Sections(val title: String) { Topics("Topics"), diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/state/RefreshableUiState.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/state/RefreshableUiState.kt new file mode 100644 index 0000000000..f3b64cdc16 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/state/RefreshableUiState.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2020 Google, Inc. + * + * 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.jetnews.ui.state + +import androidx.compose.Composable +import androidx.compose.onActive +import androidx.compose.state +import com.example.jetnews.data.Result + +/** + * Model for UiStates that can refresh. The Success state contains whether there's data loading + * apart from the current data to display on the screen. The error state also returns previously + * loaded data apart from the error. + */ +sealed class RefreshableUiState { + data class Success(val data: T?, val loading: Boolean) : RefreshableUiState() + data class Error(val exception: Exception, val previousData: T?) : + RefreshableUiState() +} + +/** + * Handler that allows getting the current RefreshableUiState and refresh its content. + */ +data class RefreshableUiStateHandler( + val state: RefreshableUiState, + val refreshAction: () -> Unit +) + +/** + * Refreshable UiState factory that updates its internal state with the + * [com.example.jetnews.data.Result] of a callback passed as a parameter. + * + * To load asynchronous data, effects are better pattern than using @Model classes since + * effects are Compose lifecycle aware. + */ +@Composable +fun refreshableUiStateFrom( + repositoryCall: RepositoryCall +): RefreshableUiStateHandler { + + var state: RefreshableUiState by state { + RefreshableUiState.Success(data = null, loading = true) + } + + val refresh = { + state = RefreshableUiState.Success(data = state.currentData, loading = true) + repositoryCall { result -> + state = when (result) { + is Result.Success -> RefreshableUiState.Success( + data = result.data, loading = false + ) + is Result.Error -> RefreshableUiState.Error( + exception = result.exception, previousData = state.currentData + ) + } + } + } + + onActive { + refresh() + } + + return RefreshableUiStateHandler(state, refresh) +} + +val RefreshableUiState.loading: Boolean + get() = this is RefreshableUiState.Success && this.loading && this.data == null + +val RefreshableUiState.refreshing: Boolean + get() = this is RefreshableUiState.Success && this.loading && this.data != null + +val RefreshableUiState.currentData: T? + get() = when (this) { + is RefreshableUiState.Success -> this.data + is RefreshableUiState.Error -> this.previousData + } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/UiState.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/state/UiState.kt similarity index 98% rename from JetNews/app/src/main/java/com/example/jetnews/ui/UiState.kt rename to JetNews/app/src/main/java/com/example/jetnews/ui/state/UiState.kt index a40e546b41..98e38296a6 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/UiState.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/state/UiState.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetnews.ui +package com.example.jetnews.ui.state import androidx.compose.Composable import androidx.compose.onActive diff --git a/JetNews/build.gradle b/JetNews/build.gradle index d1e9f9386e..56eba3ac27 100644 --- a/JetNews/build.gradle +++ b/JetNews/build.gradle @@ -26,7 +26,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:4.1.0-alpha04' + classpath 'com.android.tools.build:gradle:4.1.0-alpha05' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } }