commit
89a1294c63
@ -1,22 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
Copyright 2021 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.
|
|
||||||
-->
|
|
||||||
<resources>
|
|
||||||
|
|
||||||
<style name="PlatformAdjusted.Theme.Nia" parent="NightAdjusted.Theme.Nia">
|
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
@ -1,23 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
Copyright 2021 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.
|
|
||||||
-->
|
|
||||||
<resources>
|
|
||||||
|
|
||||||
<style name="PlatformAdjusted.Theme.Nia" parent="NightAdjusted.Theme.Nia">
|
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 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
|
||||||
|
*
|
||||||
|
* https://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.google.samples.apps.nowinandroid.interests
|
||||||
|
|
||||||
|
import android.os.Build.VERSION_CODES
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.benchmark.macro.CompilationMode
|
||||||
|
import androidx.benchmark.macro.ExperimentalMetricApi
|
||||||
|
import androidx.benchmark.macro.FrameTimingMetric
|
||||||
|
import androidx.benchmark.macro.PowerCategory
|
||||||
|
import androidx.benchmark.macro.PowerCategoryDisplayLevel
|
||||||
|
import androidx.benchmark.macro.PowerMetric
|
||||||
|
import androidx.benchmark.macro.StartupMode
|
||||||
|
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.uiautomator.By
|
||||||
|
import com.google.samples.apps.nowinandroid.PACKAGE_NAME
|
||||||
|
import com.google.samples.apps.nowinandroid.allowNotifications
|
||||||
|
import com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp
|
||||||
|
import com.google.samples.apps.nowinandroid.foryou.forYouSelectTopics
|
||||||
|
import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent
|
||||||
|
import com.google.samples.apps.nowinandroid.foryou.setAppTheme
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMetricApi::class)
|
||||||
|
@RequiresApi(VERSION_CODES.Q)
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ScrollTopicListPowerMetricsBenchmark {
|
||||||
|
@get:Rule
|
||||||
|
val benchmarkRule = MacrobenchmarkRule()
|
||||||
|
|
||||||
|
private val categories = PowerCategory.values()
|
||||||
|
.associateWith { PowerCategoryDisplayLevel.TOTAL }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun benchmarkStateChangeCompilationLight() =
|
||||||
|
benchmarkStateChangeWithTheme(CompilationMode.Partial(), false)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun benchmarkStateChangeCompilationDark() =
|
||||||
|
benchmarkStateChangeWithTheme(CompilationMode.Partial(), true)
|
||||||
|
|
||||||
|
private fun benchmarkStateChangeWithTheme(compilationMode: CompilationMode, isDark: Boolean) =
|
||||||
|
benchmarkRule.measureRepeated(
|
||||||
|
packageName = PACKAGE_NAME,
|
||||||
|
metrics = listOf(FrameTimingMetric(), PowerMetric(PowerMetric.Energy(categories))),
|
||||||
|
compilationMode = compilationMode,
|
||||||
|
iterations = 2,
|
||||||
|
startupMode = StartupMode.WARM,
|
||||||
|
setupBlock = {
|
||||||
|
// Start the app
|
||||||
|
pressHome()
|
||||||
|
startActivityAndWait()
|
||||||
|
allowNotifications()
|
||||||
|
// Navigate to Settings
|
||||||
|
device.findObject(By.desc("Settings")).click()
|
||||||
|
device.waitForIdle()
|
||||||
|
setAppTheme(isDark)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
forYouWaitForContent()
|
||||||
|
forYouSelectTopics()
|
||||||
|
repeat(3) {
|
||||||
|
forYouScrollFeedDownUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,21 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
*
|
|
||||||
* https://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.google.samples.apps.nowinandroid.core.decoder
|
|
||||||
|
|
||||||
interface StringDecoder {
|
|
||||||
fun decodeString(encodedString: String): String
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
*
|
|
||||||
* https://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.google.samples.apps.nowinandroid.core.decoder
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class UriDecoder @Inject constructor() : StringDecoder {
|
|
||||||
override fun decodeString(encodedString: String): String = Uri.decode(encodedString)
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
*
|
|
||||||
* https://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.google.samples.apps.nowinandroid.core.decoder.di
|
|
||||||
|
|
||||||
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
|
|
||||||
import com.google.samples.apps.nowinandroid.core.decoder.UriDecoder
|
|
||||||
import dagger.Binds
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
abstract class StringDecoderModule {
|
|
||||||
@Binds
|
|
||||||
abstract fun bindStringDecoder(uriDecoder: UriDecoder): StringDecoder
|
|
||||||
}
|
|
@ -1,96 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
*
|
|
||||||
* https://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.google.samples.apps.nowinandroid.core.database.util
|
|
||||||
|
|
||||||
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
|
|
||||||
import org.junit.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
|
|
||||||
class NewsResourceTypeConverterTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_room_news_resource_type_converter_for_video() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.Video,
|
|
||||||
NewsResourceTypeConverter().stringToNewsResourceType("Video 📺"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_room_news_resource_type_converter_for_article() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.Article,
|
|
||||||
NewsResourceTypeConverter().stringToNewsResourceType("Article 📚"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_room_news_resource_type_converter_for_api_change() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.APIChange,
|
|
||||||
NewsResourceTypeConverter().stringToNewsResourceType("API change"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_room_news_resource_type_converter_for_codelab() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.Codelab,
|
|
||||||
NewsResourceTypeConverter().stringToNewsResourceType("Codelab"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_room_news_resource_type_converter_for_podcast() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.Podcast,
|
|
||||||
NewsResourceTypeConverter().stringToNewsResourceType("Podcast 🎙"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_room_news_resource_type_converter_for_docs() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.Docs,
|
|
||||||
NewsResourceTypeConverter().stringToNewsResourceType("Docs 📑"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_room_news_resource_type_converter_for_event() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.Event,
|
|
||||||
NewsResourceTypeConverter().stringToNewsResourceType("Event 📆"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_room_news_resource_type_converter_for_dac() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.DAC,
|
|
||||||
NewsResourceTypeConverter().stringToNewsResourceType("DAC"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_room_news_resource_type_converter_for_umm() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.Unknown,
|
|
||||||
NewsResourceTypeConverter().stringToNewsResourceType("umm"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,209 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 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
|
||||||
|
*
|
||||||
|
* https://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.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.SpringSpec
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.ScrollableState
|
||||||
|
import androidx.compose.foundation.interaction.InteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||||
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||||
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Active
|
||||||
|
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Dormant
|
||||||
|
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Inactive
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time period for showing the scrollbar thumb after interacting with it, before it fades away
|
||||||
|
*/
|
||||||
|
private const val SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS = 2_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [Scrollbar] that allows for fast scrolling of content by dragging its thumb.
|
||||||
|
* Its thumb disappears when the scrolling container is dormant.
|
||||||
|
* @param modifier a [Modifier] for the [Scrollbar]
|
||||||
|
* @param state the driving state for the [Scrollbar]
|
||||||
|
* @param orientation the orientation of the scrollbar
|
||||||
|
* @param onThumbMoved the fast scroll implementation
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ScrollableState.DraggableScrollbar(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
state: ScrollbarState,
|
||||||
|
orientation: Orientation,
|
||||||
|
onThumbMoved: (Float) -> Unit,
|
||||||
|
) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
Scrollbar(
|
||||||
|
modifier = modifier,
|
||||||
|
orientation = orientation,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
state = state,
|
||||||
|
thumb = {
|
||||||
|
DraggableScrollbarThumb(
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
orientation = orientation,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onThumbMoved = onThumbMoved,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple [Scrollbar].
|
||||||
|
* Its thumb disappears when the scrolling container is dormant.
|
||||||
|
* @param modifier a [Modifier] for the [Scrollbar]
|
||||||
|
* @param state the driving state for the [Scrollbar]
|
||||||
|
* @param orientation the orientation of the scrollbar
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ScrollableState.DecorativeScrollbar(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
state: ScrollbarState,
|
||||||
|
orientation: Orientation,
|
||||||
|
) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
Scrollbar(
|
||||||
|
modifier = modifier,
|
||||||
|
orientation = orientation,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
state = state,
|
||||||
|
thumb = {
|
||||||
|
DecorativeScrollbarThumb(
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
orientation = orientation,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scrollbar thumb that is intended to also be a touch target for fast scrolling.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ScrollableState.DraggableScrollbarThumb(
|
||||||
|
interactionSource: InteractionSource,
|
||||||
|
orientation: Orientation,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.run {
|
||||||
|
when (orientation) {
|
||||||
|
Vertical -> width(12.dp).fillMaxHeight()
|
||||||
|
Horizontal -> height(12.dp).fillMaxWidth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(
|
||||||
|
color = scrollbarThumbColor(
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A decorative scrollbar thumb used solely for communicating a user's position in a list.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ScrollableState.DecorativeScrollbarThumb(
|
||||||
|
interactionSource: InteractionSource,
|
||||||
|
orientation: Orientation,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.run {
|
||||||
|
when (orientation) {
|
||||||
|
Vertical -> width(2.dp).fillMaxHeight()
|
||||||
|
Horizontal -> height(2.dp).fillMaxWidth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(
|
||||||
|
color = scrollbarThumbColor(
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The color of the scrollbar thumb as a function of its interaction state.
|
||||||
|
* @param interactionSource source of interactions in the scrolling container
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ScrollableState.scrollbarThumbColor(
|
||||||
|
interactionSource: InteractionSource,
|
||||||
|
): Color {
|
||||||
|
var state by remember { mutableStateOf(Dormant) }
|
||||||
|
val pressed by interactionSource.collectIsPressedAsState()
|
||||||
|
val hovered by interactionSource.collectIsHoveredAsState()
|
||||||
|
val dragged by interactionSource.collectIsDraggedAsState()
|
||||||
|
val active = (canScrollForward || canScrollForward) &&
|
||||||
|
(pressed || hovered || dragged || isScrollInProgress)
|
||||||
|
|
||||||
|
val color by animateColorAsState(
|
||||||
|
targetValue = when (state) {
|
||||||
|
Active -> MaterialTheme.colorScheme.onSurface.copy(0.5f)
|
||||||
|
Inactive -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
|
||||||
|
Dormant -> Color.Transparent
|
||||||
|
},
|
||||||
|
animationSpec = SpringSpec(
|
||||||
|
stiffness = Spring.StiffnessLow,
|
||||||
|
),
|
||||||
|
label = "Scrollbar thumb color",
|
||||||
|
)
|
||||||
|
LaunchedEffect(active) {
|
||||||
|
when (active) {
|
||||||
|
true -> state = Active
|
||||||
|
false -> if (state == Active) {
|
||||||
|
state = Inactive
|
||||||
|
delay(SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS)
|
||||||
|
state = Dormant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class ThumbState {
|
||||||
|
Active, Inactive, Dormant
|
||||||
|
}
|
@ -0,0 +1,160 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 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
|
||||||
|
*
|
||||||
|
* https://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.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.ScrollableState
|
||||||
|
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.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the [ScrollbarState] for lazy layouts.
|
||||||
|
* @param itemsAvailable the total amount of items available to scroll in the layout.
|
||||||
|
* @param visibleItems a list of items currently visible in the layout.
|
||||||
|
* @param firstVisibleItemIndex a function for interpolating the first visible index in the lazy layout
|
||||||
|
* as scrolling progresses for smooth and linear scrollbar thumb progression.
|
||||||
|
* [itemsAvailable].
|
||||||
|
* @param reverseLayout if the items in the backing lazy layout are laid out in reverse order.
|
||||||
|
* */
|
||||||
|
@Composable
|
||||||
|
internal inline fun <LazyState : ScrollableState, LazyStateItem> LazyState.scrollbarState(
|
||||||
|
itemsAvailable: Int,
|
||||||
|
crossinline visibleItems: LazyState.() -> List<LazyStateItem>,
|
||||||
|
crossinline firstVisibleItemIndex: LazyState.(List<LazyStateItem>) -> Float,
|
||||||
|
crossinline itemPercentVisible: LazyState.(LazyStateItem) -> Float,
|
||||||
|
crossinline reverseLayout: LazyState.() -> Boolean,
|
||||||
|
): ScrollbarState {
|
||||||
|
var state by remember { mutableStateOf(ScrollbarState.FULL) }
|
||||||
|
|
||||||
|
LaunchedEffect(
|
||||||
|
key1 = this,
|
||||||
|
key2 = itemsAvailable,
|
||||||
|
) {
|
||||||
|
snapshotFlow {
|
||||||
|
if (itemsAvailable == 0) return@snapshotFlow null
|
||||||
|
|
||||||
|
val visibleItemsInfo = visibleItems(this@scrollbarState)
|
||||||
|
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
|
||||||
|
|
||||||
|
val firstIndex = min(
|
||||||
|
a = firstVisibleItemIndex(visibleItemsInfo),
|
||||||
|
b = itemsAvailable.toFloat(),
|
||||||
|
)
|
||||||
|
if (firstIndex.isNaN()) return@snapshotFlow null
|
||||||
|
|
||||||
|
val itemsVisible = visibleItemsInfo.sumOf {
|
||||||
|
itemPercentVisible(it).toDouble()
|
||||||
|
}.toFloat()
|
||||||
|
|
||||||
|
val thumbTravelPercent = min(
|
||||||
|
a = firstIndex / itemsAvailable,
|
||||||
|
b = 1f,
|
||||||
|
)
|
||||||
|
val thumbSizePercent = min(
|
||||||
|
a = itemsVisible / itemsAvailable,
|
||||||
|
b = 1f,
|
||||||
|
)
|
||||||
|
ScrollbarState(
|
||||||
|
thumbSizePercent = thumbSizePercent,
|
||||||
|
thumbMovedPercent = when {
|
||||||
|
reverseLayout() -> 1f - thumbTravelPercent
|
||||||
|
else -> thumbTravelPercent
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.filterNotNull()
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { state = it }
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linearly interpolates the index for the first item in [visibleItems] for smooth scrollbar
|
||||||
|
* progression.
|
||||||
|
* @param visibleItems a list of items currently visible in the layout.
|
||||||
|
* @param itemSize a lookup function for the size of an item in the layout.
|
||||||
|
* @param offset a lookup function for the offset of an item relative to the start of the view port.
|
||||||
|
* @param nextItemOnMainAxis a lookup function for the next item on the main axis in the direction
|
||||||
|
* of the scroll.
|
||||||
|
* @param itemIndex a lookup function for index of an item in the layout relative to
|
||||||
|
* the total amount of items available.
|
||||||
|
*
|
||||||
|
* @return a [Float] in the range [firstItemPosition..nextItemPosition) where nextItemPosition
|
||||||
|
* is the index of the consecutive item along the major axis.
|
||||||
|
* */
|
||||||
|
internal inline fun <LazyState : ScrollableState, LazyStateItem> LazyState.interpolateFirstItemIndex(
|
||||||
|
visibleItems: List<LazyStateItem>,
|
||||||
|
crossinline itemSize: LazyState.(LazyStateItem) -> Int,
|
||||||
|
crossinline offset: LazyState.(LazyStateItem) -> Int,
|
||||||
|
crossinline nextItemOnMainAxis: LazyState.(LazyStateItem) -> LazyStateItem?,
|
||||||
|
crossinline itemIndex: (LazyStateItem) -> Int,
|
||||||
|
): Float {
|
||||||
|
if (visibleItems.isEmpty()) return 0f
|
||||||
|
|
||||||
|
val firstItem = visibleItems.first()
|
||||||
|
val firstItemIndex = itemIndex(firstItem)
|
||||||
|
|
||||||
|
if (firstItemIndex < 0) return Float.NaN
|
||||||
|
|
||||||
|
val firstItemSize = itemSize(firstItem)
|
||||||
|
if (firstItemSize == 0) return Float.NaN
|
||||||
|
|
||||||
|
val itemOffset = offset(firstItem).toFloat()
|
||||||
|
val offsetPercentage = abs(itemOffset) / firstItemSize
|
||||||
|
|
||||||
|
val nextItem = nextItemOnMainAxis(firstItem) ?: return firstItemIndex + offsetPercentage
|
||||||
|
|
||||||
|
val nextItemIndex = itemIndex(nextItem)
|
||||||
|
|
||||||
|
return firstItemIndex + ((nextItemIndex - firstItemIndex) * offsetPercentage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the percentage of an item that is currently visible in the view port.
|
||||||
|
* @param itemSize the size of the item
|
||||||
|
* @param itemStartOffset the start offset of the item relative to the view port start
|
||||||
|
* @param viewportStartOffset the start offset of the view port
|
||||||
|
* @param viewportEndOffset the end offset of the view port
|
||||||
|
*/
|
||||||
|
internal fun itemVisibilityPercentage(
|
||||||
|
itemSize: Int,
|
||||||
|
itemStartOffset: Int,
|
||||||
|
viewportStartOffset: Int,
|
||||||
|
viewportEndOffset: Int,
|
||||||
|
): Float {
|
||||||
|
if (itemSize == 0) return 0f
|
||||||
|
val itemEnd = itemStartOffset + itemSize
|
||||||
|
val startOffset = when {
|
||||||
|
itemStartOffset > viewportStartOffset -> 0
|
||||||
|
else -> abs(abs(viewportStartOffset) - abs(itemStartOffset))
|
||||||
|
}
|
||||||
|
val endOffset = when {
|
||||||
|
itemEnd < viewportEndOffset -> 0
|
||||||
|
else -> abs(abs(itemEnd) - abs(viewportEndOffset))
|
||||||
|
}
|
||||||
|
val size = itemSize.toFloat()
|
||||||
|
return (size - startOffset - endOffset) / size
|
||||||
|
}
|
@ -0,0 +1,402 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 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
|
||||||
|
*
|
||||||
|
* https://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.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||||
|
import androidx.compose.foundation.hoverable
|
||||||
|
import androidx.compose.foundation.interaction.DragInteraction
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.PressInteraction
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.max
|
||||||
|
import androidx.compose.ui.util.packFloats
|
||||||
|
import androidx.compose.ui.util.unpackFloat1
|
||||||
|
import androidx.compose.ui.util.unpackFloat2
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The delay between scrolls when a user long presses on the scrollbar track to initiate a scroll
|
||||||
|
* instead of dragging the scrollbar thumb.
|
||||||
|
*/
|
||||||
|
private const val SCROLLBAR_PRESS_DELAY_MS = 10L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The percentage displacement of the scrollbar when scrolled by long presses on the scrollbar
|
||||||
|
* track.
|
||||||
|
*/
|
||||||
|
private const val SCROLLBAR_PRESS_DELTA_PCT = 0.02f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class definition for the core properties of a scroll bar
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
@JvmInline
|
||||||
|
value class ScrollbarState internal constructor(
|
||||||
|
internal val packedValue: Long,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val FULL = ScrollbarState(
|
||||||
|
thumbSizePercent = 1f,
|
||||||
|
thumbMovedPercent = 0f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class definition for the core properties of a scroll bar track
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
@JvmInline
|
||||||
|
private value class ScrollbarTrack(
|
||||||
|
val packedValue: Long,
|
||||||
|
) {
|
||||||
|
constructor(
|
||||||
|
max: Float,
|
||||||
|
min: Float,
|
||||||
|
) : this(packFloats(max, min))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a [ScrollbarState] with the listed properties
|
||||||
|
* @param thumbSizePercent the thumb size of the scrollbar as a percentage of the total track size.
|
||||||
|
* Refers to either the thumb width (for horizontal scrollbars)
|
||||||
|
* or height (for vertical scrollbars).
|
||||||
|
* @param thumbMovedPercent the distance the thumb has traveled as a percentage of total
|
||||||
|
* track size.
|
||||||
|
*/
|
||||||
|
fun ScrollbarState(
|
||||||
|
thumbSizePercent: Float,
|
||||||
|
thumbMovedPercent: Float,
|
||||||
|
) = ScrollbarState(
|
||||||
|
packFloats(
|
||||||
|
val1 = thumbSizePercent,
|
||||||
|
val2 = thumbMovedPercent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the thumb size of the scrollbar as a percentage of the total track size
|
||||||
|
*/
|
||||||
|
val ScrollbarState.thumbSizePercent
|
||||||
|
get() = unpackFloat1(packedValue)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the distance the thumb has traveled as a percentage of total track size
|
||||||
|
*/
|
||||||
|
val ScrollbarState.thumbMovedPercent
|
||||||
|
get() = unpackFloat2(packedValue)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the size of the scrollbar track in pixels
|
||||||
|
*/
|
||||||
|
private val ScrollbarTrack.size
|
||||||
|
get() = unpackFloat2(packedValue) - unpackFloat1(packedValue)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the position of the scrollbar thumb on the track as a percentage
|
||||||
|
*/
|
||||||
|
private fun ScrollbarTrack.thumbPosition(
|
||||||
|
dimension: Float,
|
||||||
|
): Float = max(
|
||||||
|
a = min(
|
||||||
|
a = dimension / size,
|
||||||
|
b = 1f,
|
||||||
|
),
|
||||||
|
b = 0f,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the value of [offset] along the axis specified by [this]
|
||||||
|
*/
|
||||||
|
internal fun Orientation.valueOf(offset: Offset) = when (this) {
|
||||||
|
Orientation.Horizontal -> offset.x
|
||||||
|
Orientation.Vertical -> offset.y
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the value of [intSize] along the axis specified by [this]
|
||||||
|
*/
|
||||||
|
internal fun Orientation.valueOf(intSize: IntSize) = when (this) {
|
||||||
|
Orientation.Horizontal -> intSize.width
|
||||||
|
Orientation.Vertical -> intSize.height
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the value of [intOffset] along the axis specified by [this]
|
||||||
|
*/
|
||||||
|
internal fun Orientation.valueOf(intOffset: IntOffset) = when (this) {
|
||||||
|
Orientation.Horizontal -> intOffset.x
|
||||||
|
Orientation.Vertical -> intOffset.y
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Composable for drawing a scrollbar
|
||||||
|
* @param orientation the scroll direction of the scrollbar
|
||||||
|
* @param state the state describing the position of the scrollbar
|
||||||
|
* @param minThumbSize the minimum size of the scrollbar thumb
|
||||||
|
* @param interactionSource allows for observing the state of the scroll bar
|
||||||
|
* @param thumb a composable for drawing the scrollbar thumb
|
||||||
|
* @param onThumbMoved an function for reacting to scroll bar displacements caused by direct
|
||||||
|
* interactions on the scrollbar thumb by the user, for example implementing a fast scroll
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Scrollbar(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
orientation: Orientation,
|
||||||
|
state: ScrollbarState,
|
||||||
|
minThumbSize: Dp = 40.dp,
|
||||||
|
interactionSource: MutableInteractionSource? = null,
|
||||||
|
thumb: @Composable () -> Unit,
|
||||||
|
onThumbMoved: ((Float) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val localDensity = LocalDensity.current
|
||||||
|
|
||||||
|
// Using Offset.Unspecified and Float.NaN instead of null
|
||||||
|
// to prevent unnecessary boxing of primitives
|
||||||
|
var pressedOffset by remember { mutableStateOf(Offset.Unspecified) }
|
||||||
|
var draggedOffset by remember { mutableStateOf(Offset.Unspecified) }
|
||||||
|
|
||||||
|
// Used to immediately show drag feedback in the UI while the scrolling implementation
|
||||||
|
// catches up
|
||||||
|
var interactionThumbTravelPercent by remember { mutableStateOf(Float.NaN) }
|
||||||
|
|
||||||
|
var track by remember { mutableStateOf(ScrollbarTrack(packedValue = 0)) }
|
||||||
|
|
||||||
|
val thumbTravelPercent = when {
|
||||||
|
interactionThumbTravelPercent.isNaN() -> state.thumbMovedPercent
|
||||||
|
else -> interactionThumbTravelPercent
|
||||||
|
}
|
||||||
|
val thumbSizePx = max(
|
||||||
|
a = state.thumbSizePercent * track.size,
|
||||||
|
b = with(localDensity) { minThumbSize.toPx() },
|
||||||
|
)
|
||||||
|
val thumbSizeDp by animateDpAsState(
|
||||||
|
targetValue = with(localDensity) { thumbSizePx.toDp() },
|
||||||
|
label = "scrollbar thumb size",
|
||||||
|
)
|
||||||
|
val thumbMovedPx = min(
|
||||||
|
a = track.size * thumbTravelPercent,
|
||||||
|
b = track.size - thumbSizePx,
|
||||||
|
)
|
||||||
|
|
||||||
|
// scrollbar track container
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.run {
|
||||||
|
val withHover = interactionSource?.let(::hoverable) ?: this
|
||||||
|
when (orientation) {
|
||||||
|
Orientation.Vertical -> withHover.fillMaxHeight()
|
||||||
|
Orientation.Horizontal -> withHover.fillMaxWidth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onGloballyPositioned { coordinates ->
|
||||||
|
val scrollbarStartCoordinate = orientation.valueOf(coordinates.positionInRoot())
|
||||||
|
track = ScrollbarTrack(
|
||||||
|
max = scrollbarStartCoordinate,
|
||||||
|
min = scrollbarStartCoordinate + orientation.valueOf(coordinates.size),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Process scrollbar presses
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onPress = { offset ->
|
||||||
|
try {
|
||||||
|
// Wait for a long press before scrolling
|
||||||
|
withTimeout(viewConfiguration.longPressTimeoutMillis) {
|
||||||
|
tryAwaitRelease()
|
||||||
|
}
|
||||||
|
} catch (e: TimeoutCancellationException) {
|
||||||
|
// Start the press triggered scroll
|
||||||
|
val initialPress = PressInteraction.Press(offset)
|
||||||
|
interactionSource?.tryEmit(initialPress)
|
||||||
|
|
||||||
|
pressedOffset = offset
|
||||||
|
interactionSource?.tryEmit(
|
||||||
|
when {
|
||||||
|
tryAwaitRelease() -> PressInteraction.Release(initialPress)
|
||||||
|
else -> PressInteraction.Cancel(initialPress)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// End the press
|
||||||
|
pressedOffset = Offset.Unspecified
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Process scrollbar drags
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
var dragInteraction: DragInteraction.Start? = null
|
||||||
|
val onDragStart: (Offset) -> Unit = { offset ->
|
||||||
|
val start = DragInteraction.Start()
|
||||||
|
dragInteraction = start
|
||||||
|
interactionSource?.tryEmit(start)
|
||||||
|
draggedOffset = offset
|
||||||
|
}
|
||||||
|
val onDragEnd: () -> Unit = {
|
||||||
|
dragInteraction?.let { interactionSource?.tryEmit(DragInteraction.Stop(it)) }
|
||||||
|
draggedOffset = Offset.Unspecified
|
||||||
|
}
|
||||||
|
val onDragCancel: () -> Unit = {
|
||||||
|
dragInteraction?.let { interactionSource?.tryEmit(DragInteraction.Cancel(it)) }
|
||||||
|
draggedOffset = Offset.Unspecified
|
||||||
|
}
|
||||||
|
val onDrag: (change: PointerInputChange, dragAmount: Float) -> Unit =
|
||||||
|
onDrag@{ _, delta ->
|
||||||
|
if (draggedOffset == Offset.Unspecified) return@onDrag
|
||||||
|
draggedOffset = when (orientation) {
|
||||||
|
Orientation.Vertical -> draggedOffset.copy(
|
||||||
|
y = draggedOffset.y + delta,
|
||||||
|
)
|
||||||
|
|
||||||
|
Orientation.Horizontal -> draggedOffset.copy(
|
||||||
|
x = draggedOffset.x + delta,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (orientation) {
|
||||||
|
Orientation.Horizontal -> detectHorizontalDragGestures(
|
||||||
|
onDragStart = onDragStart,
|
||||||
|
onDragEnd = onDragEnd,
|
||||||
|
onDragCancel = onDragCancel,
|
||||||
|
onHorizontalDrag = onDrag,
|
||||||
|
)
|
||||||
|
|
||||||
|
Orientation.Vertical -> detectVerticalDragGestures(
|
||||||
|
onDragStart = onDragStart,
|
||||||
|
onDragEnd = onDragEnd,
|
||||||
|
onDragCancel = onDragCancel,
|
||||||
|
onVerticalDrag = onDrag,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val scrollbarThumbMovedDp = max(
|
||||||
|
a = with(localDensity) { thumbMovedPx.toDp() },
|
||||||
|
b = 0.dp,
|
||||||
|
)
|
||||||
|
// scrollbar thumb container
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.run {
|
||||||
|
when (orientation) {
|
||||||
|
Orientation.Horizontal -> width(thumbSizeDp)
|
||||||
|
Orientation.Vertical -> height(thumbSizeDp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.offset(
|
||||||
|
y = when (orientation) {
|
||||||
|
Orientation.Horizontal -> 0.dp
|
||||||
|
Orientation.Vertical -> scrollbarThumbMovedDp
|
||||||
|
},
|
||||||
|
x = when (orientation) {
|
||||||
|
Orientation.Horizontal -> scrollbarThumbMovedDp
|
||||||
|
Orientation.Vertical -> 0.dp
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
thumb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onThumbMoved == null) return
|
||||||
|
|
||||||
|
// State that will be read inside the effects that follow
|
||||||
|
// but will not cause re-triggering of them
|
||||||
|
val updatedState by rememberUpdatedState(state)
|
||||||
|
|
||||||
|
// Process presses
|
||||||
|
LaunchedEffect(pressedOffset) {
|
||||||
|
// Press ended, reset interactionThumbTravelPercent
|
||||||
|
if (pressedOffset == Offset.Unspecified) {
|
||||||
|
interactionThumbTravelPercent = Float.NaN
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentThumbMovedPercent = updatedState.thumbMovedPercent
|
||||||
|
val destinationThumbMovedPercent = track.thumbPosition(
|
||||||
|
dimension = orientation.valueOf(pressedOffset),
|
||||||
|
)
|
||||||
|
val isPositive = currentThumbMovedPercent < destinationThumbMovedPercent
|
||||||
|
val delta = SCROLLBAR_PRESS_DELTA_PCT * if (isPositive) 1f else -1f
|
||||||
|
|
||||||
|
while (currentThumbMovedPercent != destinationThumbMovedPercent) {
|
||||||
|
currentThumbMovedPercent = when {
|
||||||
|
isPositive -> min(
|
||||||
|
a = currentThumbMovedPercent + delta,
|
||||||
|
b = destinationThumbMovedPercent,
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> max(
|
||||||
|
a = currentThumbMovedPercent + delta,
|
||||||
|
b = destinationThumbMovedPercent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onThumbMoved(currentThumbMovedPercent)
|
||||||
|
interactionThumbTravelPercent = currentThumbMovedPercent
|
||||||
|
delay(SCROLLBAR_PRESS_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process drags
|
||||||
|
LaunchedEffect(draggedOffset) {
|
||||||
|
if (draggedOffset == Offset.Unspecified) {
|
||||||
|
interactionThumbTravelPercent = Float.NaN
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
val currentTravel = track.thumbPosition(
|
||||||
|
dimension = orientation.valueOf(draggedOffset),
|
||||||
|
)
|
||||||
|
onThumbMoved(currentTravel)
|
||||||
|
interactionThumbTravelPercent = currentTravel
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,104 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2021 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
|
||||||
|
*
|
||||||
|
* https://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.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates a [ScrollbarState] driven by the changes in a [LazyListState].
|
||||||
|
*
|
||||||
|
* @param itemsAvailable the total amount of items available to scroll in the lazy list.
|
||||||
|
* @param itemIndex a lookup function for index of an item in the list relative to [itemsAvailable].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LazyListState.scrollbarState(
|
||||||
|
itemsAvailable: Int,
|
||||||
|
itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index,
|
||||||
|
): ScrollbarState =
|
||||||
|
scrollbarState(
|
||||||
|
itemsAvailable = itemsAvailable,
|
||||||
|
visibleItems = { layoutInfo.visibleItemsInfo },
|
||||||
|
firstVisibleItemIndex = { visibleItems ->
|
||||||
|
interpolateFirstItemIndex(
|
||||||
|
visibleItems = visibleItems,
|
||||||
|
itemSize = { it.size },
|
||||||
|
offset = { it.offset },
|
||||||
|
nextItemOnMainAxis = { first -> visibleItems.find { it != first } },
|
||||||
|
itemIndex = itemIndex,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
itemPercentVisible = itemPercentVisible@{ itemInfo ->
|
||||||
|
itemVisibilityPercentage(
|
||||||
|
itemSize = itemInfo.size,
|
||||||
|
itemStartOffset = itemInfo.offset,
|
||||||
|
viewportStartOffset = layoutInfo.viewportStartOffset,
|
||||||
|
viewportEndOffset = layoutInfo.viewportEndOffset,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
reverseLayout = { layoutInfo.reverseLayout },
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates a [ScrollbarState] driven by the changes in a [LazyGridState]
|
||||||
|
*
|
||||||
|
* @param itemsAvailable the total amount of items available to scroll in the grid.
|
||||||
|
* @param itemIndex a lookup function for index of an item in the grid relative to [itemsAvailable].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LazyGridState.scrollbarState(
|
||||||
|
itemsAvailable: Int,
|
||||||
|
itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index,
|
||||||
|
): ScrollbarState =
|
||||||
|
scrollbarState(
|
||||||
|
itemsAvailable = itemsAvailable,
|
||||||
|
visibleItems = { layoutInfo.visibleItemsInfo },
|
||||||
|
firstVisibleItemIndex = { visibleItems ->
|
||||||
|
interpolateFirstItemIndex(
|
||||||
|
visibleItems = visibleItems,
|
||||||
|
itemSize = {
|
||||||
|
layoutInfo.orientation.valueOf(it.size)
|
||||||
|
},
|
||||||
|
offset = { layoutInfo.orientation.valueOf(it.offset) },
|
||||||
|
nextItemOnMainAxis = { first ->
|
||||||
|
when (layoutInfo.orientation) {
|
||||||
|
Orientation.Vertical -> visibleItems.find {
|
||||||
|
it != first && it.row != first.row
|
||||||
|
}
|
||||||
|
|
||||||
|
Orientation.Horizontal -> visibleItems.find {
|
||||||
|
it != first && it.column != first.column
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemIndex = itemIndex,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
itemPercentVisible = itemPercentVisible@{ itemInfo ->
|
||||||
|
itemVisibilityPercentage(
|
||||||
|
itemSize = layoutInfo.orientation.valueOf(itemInfo.size),
|
||||||
|
itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset),
|
||||||
|
viewportStartOffset = layoutInfo.viewportStartOffset,
|
||||||
|
viewportEndOffset = layoutInfo.viewportEndOffset,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
reverseLayout = { layoutInfo.reverseLayout },
|
||||||
|
)
|
@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 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
|
||||||
|
*
|
||||||
|
* https://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.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
|
||||||
|
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
|
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.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyListState]
|
||||||
|
* @param itemsAvailable the amount of items in the list.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LazyListState.rememberDraggableScroller(
|
||||||
|
itemsAvailable: Int,
|
||||||
|
): (Float) -> Unit = rememberDraggableScroller(
|
||||||
|
itemsAvailable = itemsAvailable,
|
||||||
|
scroll = ::scrollToItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyGridState]
|
||||||
|
* @param itemsAvailable the amount of items in the grid.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LazyGridState.rememberDraggableScroller(
|
||||||
|
itemsAvailable: Int,
|
||||||
|
): (Float) -> Unit = rememberDraggableScroller(
|
||||||
|
itemsAvailable = itemsAvailable,
|
||||||
|
scroll = ::scrollToItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic function to react to [Scrollbar] thumb displacements in a lazy layout.
|
||||||
|
* @param itemsAvailable the total amount of items available to scroll in the layout.
|
||||||
|
* @param scroll a function to be invoked when an index has been identified to scroll to.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private inline fun rememberDraggableScroller(
|
||||||
|
itemsAvailable: Int,
|
||||||
|
crossinline scroll: suspend (index: Int) -> Unit,
|
||||||
|
): (Float) -> Unit {
|
||||||
|
var percentage by remember { mutableStateOf(Float.NaN) }
|
||||||
|
val itemCount by rememberUpdatedState(itemsAvailable)
|
||||||
|
|
||||||
|
LaunchedEffect(percentage) {
|
||||||
|
if (percentage.isNaN()) return@LaunchedEffect
|
||||||
|
val indexToFind = (itemCount * percentage).toInt()
|
||||||
|
scroll(indexToFind)
|
||||||
|
}
|
||||||
|
return remember {
|
||||||
|
{ newPercentage -> percentage = newPercentage }
|
||||||
|
}
|
||||||
|
}
|
@ -1,80 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
*
|
|
||||||
* https://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.google.samples.apps.nowinandroid.core.model.data
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type for [NewsResource]
|
|
||||||
*/
|
|
||||||
enum class NewsResourceType(
|
|
||||||
val serializedName: String,
|
|
||||||
val displayText: String,
|
|
||||||
// TODO: descriptions should probably be string resources
|
|
||||||
val description: String,
|
|
||||||
) {
|
|
||||||
Video(
|
|
||||||
serializedName = "Video 📺",
|
|
||||||
displayText = "Video 📺",
|
|
||||||
description = "A video published on YouTube",
|
|
||||||
),
|
|
||||||
APIChange(
|
|
||||||
serializedName = "API change",
|
|
||||||
displayText = "API change",
|
|
||||||
description = "An addition, deprecation or change to the Android platform APIs.",
|
|
||||||
),
|
|
||||||
Article(
|
|
||||||
serializedName = "Article 📚",
|
|
||||||
displayText = "Article 📚",
|
|
||||||
description = "An article, typically on Medium or the official Android blog",
|
|
||||||
),
|
|
||||||
Codelab(
|
|
||||||
serializedName = "Codelab",
|
|
||||||
displayText = "Codelab",
|
|
||||||
description = "A new or updated codelab",
|
|
||||||
),
|
|
||||||
Podcast(
|
|
||||||
serializedName = "Podcast 🎙",
|
|
||||||
displayText = "Podcast 🎙",
|
|
||||||
description = "A podcast",
|
|
||||||
),
|
|
||||||
Docs(
|
|
||||||
serializedName = "Docs 📑",
|
|
||||||
displayText = "Docs 📑",
|
|
||||||
description = "A new or updated piece of documentation",
|
|
||||||
),
|
|
||||||
Event(
|
|
||||||
serializedName = "Event 📆",
|
|
||||||
displayText = "Event 📆",
|
|
||||||
description = "Information about a developer event e.g. Android Developer Summit",
|
|
||||||
),
|
|
||||||
DAC(
|
|
||||||
serializedName = "DAC",
|
|
||||||
displayText = "DAC",
|
|
||||||
description = "Android version features - Information about features in an Android",
|
|
||||||
),
|
|
||||||
Unknown(
|
|
||||||
serializedName = "Unknown",
|
|
||||||
displayText = "Unknown",
|
|
||||||
description = "Unknown",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
fun String?.asNewsResourceType() = when (this) {
|
|
||||||
null -> NewsResourceType.Unknown
|
|
||||||
else -> NewsResourceType.values()
|
|
||||||
.firstOrNull { type -> type.serializedName == this }
|
|
||||||
?: NewsResourceType.Unknown
|
|
||||||
}
|
|
@ -1,39 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
*
|
|
||||||
* https://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.google.samples.apps.nowinandroid.core.network.model.util
|
|
||||||
|
|
||||||
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
|
|
||||||
import com.google.samples.apps.nowinandroid.core.model.data.asNewsResourceType
|
|
||||||
import kotlinx.serialization.KSerializer
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind.STRING
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
|
||||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
|
||||||
import kotlinx.serialization.encoding.Decoder
|
|
||||||
import kotlinx.serialization.encoding.Encoder
|
|
||||||
|
|
||||||
object NewsResourceTypeSerializer : KSerializer<NewsResourceType> {
|
|
||||||
override fun deserialize(decoder: Decoder): NewsResourceType =
|
|
||||||
decoder.decodeString().asNewsResourceType()
|
|
||||||
|
|
||||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(
|
|
||||||
serialName = "type",
|
|
||||||
kind = STRING,
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun serialize(encoder: Encoder, value: NewsResourceType) =
|
|
||||||
encoder.encodeString(value.serializedName)
|
|
||||||
}
|
|
@ -1,106 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
*
|
|
||||||
* https://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.google.samples.apps.nowinandroid.core.network.model.util
|
|
||||||
|
|
||||||
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.junit.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
|
|
||||||
class NewsResourceTypeSerializerTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_news_resource_serializer_video() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.Video,
|
|
||||||
Json.decodeFromString(NewsResourceTypeSerializer, """"Video 📺""""),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_news_resource_serializer_article() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.Article,
|
|
||||||
Json.decodeFromString(NewsResourceTypeSerializer, """"Article 📚""""),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_news_resource_serializer_api_change() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.APIChange,
|
|
||||||
Json.decodeFromString(NewsResourceTypeSerializer, """"API change""""),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_news_resource_serializer_codelab() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.Codelab,
|
|
||||||
Json.decodeFromString(NewsResourceTypeSerializer, """"Codelab""""),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_news_resource_serializer_podcast() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.Podcast,
|
|
||||||
Json.decodeFromString(NewsResourceTypeSerializer, """"Podcast 🎙""""),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_news_resource_serializer_docs() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.Docs,
|
|
||||||
Json.decodeFromString(NewsResourceTypeSerializer, """"Docs 📑""""),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_news_resource_serializer_event() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.Event,
|
|
||||||
Json.decodeFromString(NewsResourceTypeSerializer, """"Event 📆""""),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_news_resource_serializer_dac() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.DAC,
|
|
||||||
Json.decodeFromString(NewsResourceTypeSerializer, """"DAC""""),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_news_resource_serializer_unknown() {
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.Unknown,
|
|
||||||
Json.decodeFromString(NewsResourceTypeSerializer, """"umm""""),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun test_serialize_and_deserialize() {
|
|
||||||
val json = Json.encodeToString(NewsResourceTypeSerializer, NewsResourceType.Video)
|
|
||||||
assertEquals(
|
|
||||||
NewsResourceType.Video,
|
|
||||||
Json.decodeFromString(NewsResourceTypeSerializer, json),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
*
|
|
||||||
* https://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.google.samples.apps.nowinandroid.core.testing.decoder
|
|
||||||
|
|
||||||
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class FakeStringDecoder @Inject constructor() : StringDecoder {
|
|
||||||
override fun decodeString(encodedString: String): String = encodedString
|
|
||||||
}
|
|
@ -1,35 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
*
|
|
||||||
* https://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.google.samples.apps.nowinandroid.core.testing.di
|
|
||||||
|
|
||||||
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
|
|
||||||
import com.google.samples.apps.nowinandroid.core.decoder.di.StringDecoderModule
|
|
||||||
import com.google.samples.apps.nowinandroid.core.testing.decoder.FakeStringDecoder
|
|
||||||
import dagger.Binds
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import dagger.hilt.testing.TestInstallIn
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@TestInstallIn(
|
|
||||||
components = [SingletonComponent::class],
|
|
||||||
replaces = [StringDecoderModule::class],
|
|
||||||
)
|
|
||||||
abstract class TestStringDecoderModule {
|
|
||||||
@Binds
|
|
||||||
abstract fun bindsStringDecoder(fakeStringDecoder: FakeStringDecoder): StringDecoder
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue