Add fake splash screen for launching apps by gestures

This commit is contained in:
MM20 2023-02-26 00:12:56 +01:00
parent c664f2e777
commit 608d73706a
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
8 changed files with 357 additions and 27 deletions

View File

@ -0,0 +1,161 @@
package de.mm20.launcher2.ui.component
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.TypedValue
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import coil.compose.AsyncImage
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.data.LauncherApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun FakeSplashScreen(
modifier: Modifier = Modifier,
searchable: SavableSearchable? = null
) {
val splashScreenData = rememberSplashScreenData(searchable)
val animatedBackgroundColor by animateColorAsState(splashScreenData.backgroundColor)
Surface(
modifier = modifier
.fillMaxSize(),
shadowElevation = 4.dp,
color = animatedBackgroundColor,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
AsyncImage(
modifier = Modifier.size(288.dp),
model = splashScreenData.icon,
contentDescription = null
)
AsyncImage(
modifier = Modifier
.padding(bottom = 60.dp)
.width(200.dp)
.height(80.dp)
.align(Alignment.BottomCenter),
model = splashScreenData.brandingIcon,
contentDescription = null
)
}
}
}
data class SplashScreenData(
val backgroundColor: Color,
val icon: Drawable? = null,
val brandingIcon: Drawable? = null,
val iconBackground: Color? = null,
)
@Composable
fun rememberSplashScreenData(searchable: SavableSearchable?): SplashScreenData {
val context = LocalContext.current
val defaultBackgroundColor = MaterialTheme.colorScheme.background
val state = remember {
mutableStateOf(SplashScreenData(backgroundColor = defaultBackgroundColor))
}
LaunchedEffect(searchable) {
withContext(Dispatchers.IO) {
if (searchable is LauncherApp && isAtLeastApiLevel(31)) {
val activityInfo = searchable.launcherActivityInfo.activityInfo
val themeRes = activityInfo.themeResource
val ctx = context.createPackageContext(
searchable.`package`,
Context.CONTEXT_IGNORE_SECURITY
)
ctx.setTheme(themeRes)
val theme = ctx.theme
val typedValue = TypedValue()
theme.resolveAttribute(
android.R.attr.windowSplashScreenBackground,
typedValue,
true
)
if (!typedValue.isColorType || typedValue.data == 0) {
theme.resolveAttribute(
android.R.attr.windowBackground,
typedValue,
true
)
}
if (!typedValue.isColorType || typedValue.data == 0) {
theme.resolveAttribute(
android.R.attr.colorBackground,
typedValue,
true
)
}
if (!typedValue.isColorType || typedValue.data == 0) {
theme.resolveAttribute(
android.R.attr.background,
typedValue,
true
)
}
val backgroundColor = typedValue.takeIf { it.isColorType && it.data != 0 }?.data
theme.resolveAttribute(
android.R.attr.windowSplashScreenAnimatedIcon,
typedValue,
true
)
val icon = if (typedValue.resourceId != 0) {
ContextCompat.getDrawable(ctx, typedValue.resourceId)
} else {
null
}
theme.resolveAttribute(
android.R.attr.windowSplashScreenBrandingImage,
typedValue,
true
)
val brandingIcon = if (typedValue.resourceId != 0) {
ContextCompat.getDrawable(ctx, typedValue.resourceId)
} else {
null
}
state.value = SplashScreenData(
backgroundColor = backgroundColor?.let { Color(it) } ?: defaultBackgroundColor,
icon = icon,
brandingIcon = brandingIcon
)
}
}
}
return state.value
}

View File

@ -38,6 +38,7 @@ class GestureDetector {
dragStart = null
currentDrag = null
hasDragEnded = false
gestureListener?.onDragEnd()
}
@ -51,6 +52,8 @@ class GestureDetector {
* The gesture detector will no longer track the drag gesture in this case.
*/
fun onDrag(offset: Offset): Boolean = false
fun onDragEnd() {}
}
}

View File

@ -11,6 +11,7 @@ fun GestureHandler(
onLongPress: (Offset) -> Unit = {},
onDoubleTap: (Offset) -> Unit = {},
onDrag: (Offset) -> Boolean = { false },
onDragEnd: () -> Unit = {},
) {
DisposableEffect(detector) {
detector.gestureListener = object : GestureDetector.OnGestureListener {
@ -29,6 +30,10 @@ fun GestureHandler(
override fun onDrag(offset: Offset): Boolean {
return onDrag(offset)
}
override fun onDragEnd() {
onDragEnd()
}
}
onDispose {
detector.gestureListener = null

View File

@ -1,5 +1,6 @@
package de.mm20.launcher2.ui.launcher
import android.app.ActivityOptions
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -15,6 +16,7 @@ import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings
import de.mm20.launcher2.preferences.Settings.GestureSettings.GestureAction
import de.mm20.launcher2.preferences.Settings.LayoutSettings.Layout
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.ui.gestures.Gesture
import kotlinx.coroutines.flow.MutableStateFlow
@ -57,7 +59,8 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent {
isSystemInDarkMode.value = darkMode
}
val baseLayout = dataStore.data.map { it.layout.baseLayout }.asLiveData()
val baseLayout = dataStore.data.map { it.layout.baseLayout }
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
val bottomSearchBar = dataStore.data.map { it.layout.bottomSearchBar }.asLiveData()
val reverseSearchResults = dataStore.data.map { it.layout.reverseSearchResults }.asLiveData()
val fixedSearchBar = dataStore.data.map { it.layout.fixedSearchBar }.asLiveData()
@ -109,18 +112,31 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent {
val gestureState: StateFlow<GestureState> = dataStore
.data.map { it.gestures }
.distinctUntilChanged()
.map { settings ->
val swipeLeftAction = settings?.swipeLeft ?: GestureAction.None
val swipeRightAction = settings?.swipeRight ?: GestureAction.None
val swipeDownAction = settings?.swipeDown ?: GestureAction.None
.combine(baseLayout) { settings, layout ->
val swipeLeftAction =
settings?.swipeLeft?.takeIf { layout != Layout.Pager } ?: GestureAction.None
val swipeRightAction = settings?.swipeRight?.takeIf { layout != Layout.PagerReversed }
?: GestureAction.None
val swipeDownAction =
settings?.swipeDown?.takeIf { layout != Layout.PullDown } ?: GestureAction.None
val longPressAction = settings?.longPress ?: GestureAction.None
val doubleTapAction = settings?.doubleTap ?: GestureAction.None
val apps = listOfNotNull(
if (swipeLeftAction == GestureAction.LaunchApp) settings.swipeLeftApp else null,
if (swipeRightAction == GestureAction.LaunchApp) settings.swipeRightApp else null,
if (swipeDownAction == GestureAction.LaunchApp) settings.swipeDownApp else null,
if (longPressAction == GestureAction.LaunchApp) settings.longPressApp else null,
val swipeLeftAppKey =
if (swipeLeftAction == GestureAction.LaunchApp) settings.swipeLeftApp else null
val swipeRightAppKey =
if (swipeRightAction == GestureAction.LaunchApp) settings.swipeRightApp else null
val swipeDownAppKey =
if (swipeDownAction == GestureAction.LaunchApp) settings.swipeDownApp else null
val longPressAppKey =
if (longPressAction == GestureAction.LaunchApp) settings.longPressApp else null
val doubleTapAppKey =
if (doubleTapAction == GestureAction.LaunchApp) settings.doubleTapApp else null
val apps = listOfNotNull(
swipeLeftAppKey,
swipeRightAppKey,
swipeDownAppKey,
longPressAppKey,
doubleTapAppKey
).let { favoritesRepository.getFromKeys(it) }
GestureState(
@ -129,11 +145,11 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent {
swipeDownAction = swipeDownAction,
longPressAction = longPressAction,
doubleTapAction = doubleTapAction,
swipeLeftApp = apps.firstOrNull { it.key == settings?.swipeLeftApp },
swipeRightApp = apps.firstOrNull { it.key == settings?.swipeRightApp },
swipeDownApp = apps.firstOrNull { it.key == settings?.swipeDownApp },
longPressApp = apps.firstOrNull { it.key == settings?.longPressApp },
doubleTapApp = apps.firstOrNull { it.key == settings?.doubleTapApp }
swipeLeftApp = apps.firstOrNull { it.key == swipeLeftAppKey },
swipeRightApp = apps.firstOrNull { it.key == swipeRightAppKey },
swipeDownApp = apps.firstOrNull { it.key == swipeDownAppKey },
longPressApp = apps.firstOrNull { it.key == longPressAppKey },
doubleTapApp = apps.firstOrNull { it.key == doubleTapAppKey }
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, GestureState())
@ -143,9 +159,9 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent {
val action = when (gesture) {
Gesture.DoubleTap -> gestureState.value.doubleTapAction
Gesture.LongPress -> gestureState.value.longPressAction
Gesture.SwipeDown -> gestureState.value.swipeDownAction?.takeIf { baseLayout.value != Settings.LayoutSettings.Layout.PullDown }
Gesture.SwipeLeft -> gestureState.value.swipeLeftAction?.takeIf { baseLayout.value != Settings.LayoutSettings.Layout.Pager }
Gesture.SwipeRight -> gestureState.value.swipeRightAction?.takeIf { baseLayout.value != Settings.LayoutSettings.Layout.PagerReversed }
Gesture.SwipeDown -> gestureState.value.swipeDownAction.takeIf { baseLayout.value != Settings.LayoutSettings.Layout.PullDown }
Gesture.SwipeLeft -> gestureState.value.swipeLeftAction.takeIf { baseLayout.value != Settings.LayoutSettings.Layout.Pager }
Gesture.SwipeRight -> gestureState.value.swipeRightAction.takeIf { baseLayout.value != Settings.LayoutSettings.Layout.PagerReversed }
}
val requiresAccessibilityService =
action == GestureAction.OpenRecents
@ -195,13 +211,18 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent {
}
GestureAction.LaunchApp -> {
val options = ActivityOptions.makeCustomAnimation(
context,
android.R.anim.fade_in,
android.R.anim.fade_out,
)
when (gesture) {
Gesture.SwipeLeft -> gestureState.value.swipeLeftApp
Gesture.SwipeRight -> gestureState.value.swipeRightApp
Gesture.SwipeDown -> gestureState.value.swipeDownApp
Gesture.LongPress -> gestureState.value.longPressApp
Gesture.DoubleTap -> gestureState.value.doubleTapApp
}?.launch(context, null)
}?.launch(context, options.toBundle())
true
}

View File

@ -18,6 +18,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.livedata.observeAsState
@ -126,7 +127,7 @@ abstract class SharedLauncherActivity(
val hideStatus by viewModel.hideStatusBar.observeAsState(false)
val hideNav by viewModel.hideNavBar.observeAsState(false)
val layout by viewModel.baseLayout.observeAsState(null)
val layout by viewModel.baseLayout.collectAsState(null)
val bottomSearchBar by viewModel.bottomSearchBar.observeAsState(false)
val reverseSearchResults by viewModel.reverseSearchResults.observeAsState(false)
val fixedSearchBar by viewModel.fixedSearchBar.observeAsState(false)

View File

@ -1,23 +1,37 @@
package de.mm20.launcher2.ui.launcher.gestures
import android.app.WallpaperManager
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.preferences.Settings.GestureSettings.GestureAction
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.ui.component.FakeSplashScreen
import de.mm20.launcher2.ui.gestures.Gesture
import de.mm20.launcher2.ui.gestures.GestureHandler
import de.mm20.launcher2.ui.gestures.LocalGestureDetector
import de.mm20.launcher2.ui.ktx.animateTo
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.GestureState
import de.mm20.launcher2.ui.launcher.LauncherScaffoldVM
import de.mm20.launcher2.ui.launcher.sheets.FailedGestureSheet
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
@Composable
@ -27,6 +41,7 @@ fun LauncherGestureHandler() {
val gestureDetector = LocalGestureDetector.current
val viewModel: LauncherScaffoldVM = viewModel()
val scope = rememberCoroutineScope()
val gestureState by viewModel.gestureState.collectAsState(GestureState())
@ -38,8 +53,13 @@ fun LauncherGestureHandler() {
val windowToken = LocalView.current.windowToken
var launchingApp by remember { mutableStateOf<SavableSearchable?>(null) }
var swipeGestureProgress = remember { mutableStateOf(0f) }
var swipeGestureDirection by remember { mutableStateOf<SwipeDirection?>(null) }
val swipeThreshold = 150.dp.toPixels()
val swipeStartThreshold = 18.dp.toPixels()
val swipeActionThreshold = 150.dp.toPixels()
val swipeLaunchAppThreshold = 220.dp.toPixels()
GestureHandler(
detector = gestureDetector,
onDoubleTap = {
@ -49,22 +69,98 @@ fun LauncherGestureHandler() {
viewModel.handleGesture(context, Gesture.LongPress)
},
onDrag = {
when {
gestureState.swipeRightApp != null && it.x > swipeStartThreshold && (
swipeGestureDirection == SwipeDirection.Right || (swipeGestureDirection == null && it.x.absoluteValue > it.y.absoluteValue * 2f)
) -> {
swipeGestureDirection = SwipeDirection.Right
swipeGestureProgress.value =
((it.x - swipeStartThreshold) / (swipeLaunchAppThreshold - swipeStartThreshold)).coerceIn(
0f,
1f
)
launchingApp = gestureState.swipeRightApp
return@GestureHandler false
}
gestureState.swipeLeftApp != null && it.x < -swipeStartThreshold && (
swipeGestureDirection == SwipeDirection.Left || (swipeGestureDirection == null && it.x.absoluteValue > it.y.absoluteValue * 2f)
) -> {
swipeGestureDirection = SwipeDirection.Left
swipeGestureProgress.value =
((-it.x - swipeStartThreshold) / (swipeLaunchAppThreshold - swipeStartThreshold)).coerceIn(
0f,
1f
)
launchingApp = gestureState.swipeLeftApp
return@GestureHandler false
}
gestureState.swipeDownApp != null && it.y > swipeStartThreshold && (
swipeGestureDirection == SwipeDirection.Down || (swipeGestureDirection == null && it.y.absoluteValue > it.x.absoluteValue * 2f)
) -> {
swipeGestureDirection = SwipeDirection.Down
swipeGestureProgress.value =
((it.y - swipeStartThreshold) / (swipeLaunchAppThreshold - swipeStartThreshold)).coerceIn(
0f,
1f
)
launchingApp = gestureState.swipeDownApp
return@GestureHandler false
}
else -> {
swipeGestureDirection = null
swipeGestureProgress.value = 0f
launchingApp = null
}
}
return@GestureHandler when {
it.x > swipeThreshold && it.x.absoluteValue > it.y.absoluteValue * 2f -> {
it.x > swipeActionThreshold && it.x.absoluteValue > it.y.absoluteValue * 2f -> {
viewModel.handleGesture(context, Gesture.SwipeRight)
}
it.x < -swipeThreshold && it.x.absoluteValue > it.y.absoluteValue * 2f -> {
it.x < -swipeActionThreshold && it.x.absoluteValue > it.y.absoluteValue * 2f -> {
viewModel.handleGesture(context, Gesture.SwipeLeft)
}
it.y > swipeThreshold && it.y.absoluteValue > it.x.absoluteValue * 2f -> {
it.y > swipeActionThreshold && it.y.absoluteValue > it.x.absoluteValue * 2f -> {
viewModel.handleGesture(context, Gesture.SwipeDown)
}
else -> false
}
},
onDragEnd = {
if (swipeGestureProgress.value > 0f) {
scope.launch {
val direction = swipeGestureDirection
if ((swipeGestureProgress.value * swipeLaunchAppThreshold) + swipeStartThreshold >= swipeActionThreshold
&& direction != null
) {
swipeGestureProgress.animateTo(1f)
viewModel.handleGesture(
context,
when (direction) {
SwipeDirection.Right -> Gesture.SwipeRight
SwipeDirection.Left -> Gesture.SwipeLeft
SwipeDirection.Down -> Gesture.SwipeDown
},
)
delay(500)
} else {
swipeGestureProgress.animateTo(0f)
}
swipeGestureDirection = null
swipeGestureProgress.value = 0f
launchingApp = null
}
}
},
onTap = {
wallpaperManager.sendWallpaperCommand(
windowToken,
@ -76,6 +172,43 @@ fun LauncherGestureHandler() {
)
}
)
if (launchingApp != null) {
BoxWithConstraints(
modifier = Modifier.fillMaxSize()
) {
FakeSplashScreen(
modifier = Modifier
.offset {
val p = swipeGestureProgress.value
when (swipeGestureDirection) {
SwipeDirection.Right -> IntOffset(
(-swipeActionThreshold * (1f - p)).toInt(),
0
)
SwipeDirection.Left -> IntOffset(
(swipeActionThreshold * (1f - p)).toInt(),
0
)
SwipeDirection.Down -> IntOffset(
0,
(-swipeActionThreshold * (1f - p)).toInt()
)
else -> IntOffset.Zero
}
}
.graphicsLayer {
alpha = (2f * swipeGestureProgress.value).coerceAtMost(1f)
},
searchable = launchingApp,
)
}
}
if (viewModel.failedGestureState != null) {
FailedGestureSheet(
failedGesture = viewModel.failedGestureState!!,
@ -84,4 +217,10 @@ fun LauncherGestureHandler() {
}
)
}
}
internal enum class SwipeDirection {
Left,
Right,
Down
}

View File

@ -68,9 +68,6 @@ abstract class SearchableItemVM(
ActivityOptionsCompat.makeBasic()
}
val bundle = options.toBundle()
if (isAtLeastApiLevel(31)) {
bundle?.putInt("android.activity.splashScreenStyle", 1)
}
if (searchable.launch(context, bundle)) {
favoritesRepository.incrementLaunchCounter(searchable)
return true

View File

@ -120,6 +120,9 @@ data class LauncherApp(
override fun launch(context: Context, options: Bundle?): Boolean {
val launcherApps = context.getSystemService<LauncherApps>()!!
if (isAtLeastApiLevel(31)) {
options?.putInt("android.activity.splashScreenStyle", 1)
}
try {
launcherApps.startMainActivity(
ComponentName(`package`, activity),