1
0
mirror of https://github.com/AllanWang/Frost-for-Facebook.git synced 2024-09-19 15:11:42 +02:00

Merge pull request #1949 from AllanWang/tab-selector

This commit is contained in:
Allan Wang 2023-06-22 03:11:16 -07:00 committed by GitHub
commit bc07e6ec9d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 837 additions and 12 deletions

View File

@ -63,7 +63,9 @@ class FrostApp : Application() {
) )
} }
MainScope().launch { setup() } MainScope().launch {
// setup()
}
} }
private suspend fun setup() { private suspend fun setup() {

View File

@ -18,6 +18,7 @@ package com.pitchedapps.frost.compose
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
@ -27,16 +28,15 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
/** Main Frost compose theme. */ /** Main Frost compose theme. */
@Composable @Composable
fun FrostTheme( fun FrostTheme(
modifier: Modifier = Modifier,
isDarkTheme: Boolean = isSystemInDarkTheme(), isDarkTheme: Boolean = isSystemInDarkTheme(),
isDynamicColor: Boolean = true, isDynamicColor: Boolean = true,
transparent: Boolean = true, transparent: Boolean = true,
modifier: Modifier = Modifier,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -57,8 +57,8 @@ fun FrostTheme(
MaterialTheme(colorScheme = colorScheme) { MaterialTheme(colorScheme = colorScheme) {
Surface( Surface(
modifier = modifier, modifier = modifier.fillMaxSize(),
color = if (transparent) Color.Transparent else MaterialTheme.colorScheme.surface, // color = if (transparent) Color.Transparent else MaterialTheme.colorScheme.surface,
content = content, content = content,
) )
} }

View File

@ -0,0 +1,131 @@
/*
* Copyright 2023 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.compose.draggable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import com.pitchedapps.frost.ext.toDpSize
import com.pitchedapps.frost.ext.toIntOffset
/*
* Resources:
*
* https://blog.canopas.com/android-drag-and-drop-ui-element-in-jetpack-compose-14922073b3f1
*/
/**
* Container for drag interactions.
*
* This must hold all drag and drop targets.
*
* If a composable cannot be used, any container can add the [Modifier.dragContainer] call, and have
* a [DraggingContents] positioned in the same window space (ie in a Box, or with the appropriate
* offsets to match).
*/
@Composable
fun <T> DragContainer(
modifier: Modifier = Modifier,
draggableState: DraggableState<T>,
content: @Composable () -> Unit
) {
Box(modifier = modifier.dragContainer(draggableState)) {
content()
DraggingContents(draggableState = draggableState)
}
}
/**
* Drag container modifier.
*
* Containers must hold all drag and drop targets, along with [DraggingContents].
*/
fun Modifier.dragContainer(draggableState: DraggableState<*>): Modifier {
return onGloballyPositioned { draggableState.windowPosition = it.positionInWindow() }
}
/**
* Drag target modifier.
*
* This should be applied to the composable that will be dragged. The modifier will capture
* positions and hide (alpha 0) the target when dragging.
*/
fun Modifier.dragTarget(dragTargetState: DragTargetState<*>): Modifier {
return onGloballyPositioned {
dragTargetState.windowPosition = it.positionInWindow()
dragTargetState.size = it.size
}
.pointerInput(dragTargetState) {
detectDragGesturesAfterLongPress(
onDragStart = { dragTargetState.onDragStart() },
onDrag = { _, offset -> dragTargetState.onDrag(offset) },
onDragEnd = { dragTargetState.onDragEnd() },
onDragCancel = { dragTargetState.onDragEnd() },
)
}
// We still need to draw to track size changes
.alpha(if (dragTargetState.isDragging) 0f else 1f)
}
/**
* Drop target modifier.
*
* This should be applied to targets that capture drag targets. The modifier will listen to
* composable bounds.
*/
fun <T> Modifier.dropTarget(dropTargetState: DropTargetState<T>): Modifier {
return onGloballyPositioned { dropTargetState.bounds = it.boundsInWindow() }
}
/**
* Draggable content.
*
* Provides composition for all dragging targets.
*
* For composing, we take the position provided by the dragging target. We also account for the
* target size, as we want to maintain the same bounds here. As an example, the target can have
* fillMaxWidth in a grid, but would have a full parent width here without the sizing constraints.
*/
@Composable
fun <T> DraggingContents(draggableState: DraggableState<T>) {
for (target in draggableState.targets) {
DraggingContent(draggableState = draggableState, target = target)
}
}
@Composable
private fun <T> DraggingContent(draggableState: DraggableState<T>, target: DragTargetState<T>) {
val density = LocalDensity.current
Box(
modifier =
Modifier.size(target.size.toDpSize(density)).offset {
(target.dragPosition - draggableState.windowPosition).toIntOffset()
},
) {
target.dragComposable()
}
}

View File

@ -0,0 +1,268 @@
/*
* Copyright 2023 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.compose.draggable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.unit.IntSize
/** Callback when a drag completes on top of a target. */
fun interface OnDrop<T> {
fun onDrop(dragTarget: String, dragData: T, dropTarget: String)
}
/** Create draggable state, which will store and create all target states. */
@Composable
fun <T> rememberDraggableState(onDrop: OnDrop<T>): DraggableState<T> {
// State must be remembered without keys, or else updates will clear draggable data
val state = remember { DraggableStateImpl(onDrop) }
LaunchedEffect(onDrop) { state.onDrop = onDrop }
return state
}
/**
* Parent draggable state.
*
* Allows for drag and drop target state creation via [dragTarget] and [dragTarget].
*
* All public getters are states, and will trigger recomposition automatically.
*/
interface DraggableState<T> {
var windowPosition: Offset
val targets: Collection<DragTargetState<T>>
@Composable
fun rememberDragTarget(key: String, data: T, content: @Composable () -> Unit): DragTargetState<T>
@Composable fun rememberDropTarget(key: String): DropTargetState<T>
}
interface DragTargetState<T> {
/**
* Identifier for drag target.
*
* This is not necessarily unique, but only unique drag target keys may be dragged at a time. Ie,
* if a group of drag targets with the same key is dragging, only the first one will activate.
*/
val key: String
/** Data associated with this drag target. */
val data: T
/** True if target is actively being dragged. */
val isDragging: Boolean
/** Window position for target. */
var windowPosition: Offset
/** Size bounds for target. */
var size: IntSize
/** Drag position relative to window. This is only valid while [isDragging]. */
val dragPosition: Offset
/** Composable to use when dragging. */
val dragComposable: @Composable () -> Unit
/**
* Begin drag for target.
*
* If there is another target with the same [key] being dragged, we will ignore this request.
*
* Returns true if the request is accepted. It is the caller's responsibility to not propagate
* drag events if the request is denied.
*/
fun onDragStart()
fun onDrag(offset: Offset)
fun onDragEnd()
}
interface DropTargetState<T> {
/**
* True if there is a drag target above this target.
*
* [hoverData] will be populated when true.
*/
val isHovered: Boolean
/** Associated [DragTargetState.data], or null when not [isHovered] */
val hoverData: T?
/** Bounds of the drop target composable */
var bounds: Rect
}
private class DraggableStateImpl<T>(var onDrop: OnDrop<T>) : DraggableState<T> {
override var windowPosition: Offset by mutableStateOf(Offset.Zero)
val activeDragTargets = mutableStateMapOf<String, DragTargetStateImpl<T>>()
private val dropTargets = mutableStateMapOf<String, DropTargetStateImpl<T>>()
override val targets: Collection<DragTargetState<T>>
get() = activeDragTargets.values
fun cleanUpDrag(dragTarget: DragTargetStateImpl<T>) {
val dragKey = dragTarget.key
for ((dropKey, dropTarget) in dropTargets) {
if (dropTarget.hoverKey == dragKey) {
onDrop.onDrop(dragTarget = dragKey, dragData = dragTarget.data, dropTarget = dropKey)
setHover(dragKey = null, dropKey)
// Check other drag targets in case one meets drag requirements
checkForDrop(dropKey)
}
}
}
@Composable
override fun rememberDragTarget(
key: String,
data: T,
content: @Composable () -> Unit,
): DragTargetState<T> {
val target =
remember(key, data, content, this) {
DragTargetStateImpl(key = key, data = data, draggableState = this, dragComposable = content)
}
DisposableEffect(target) { onDispose { activeDragTargets.remove(key) } }
return target
}
@Composable
override fun rememberDropTarget(key: String): DropTargetState<T> {
val target = remember(key, this) { DropTargetStateImpl(key, this) }
DisposableEffect(target) {
dropTargets[key] = target
onDispose { dropTargets.remove(key) }
}
return target
}
private fun setHover(dragKey: String?, dropKey: String) {
val dropTarget = dropTargets[dropKey] ?: return
// Safety check; we only want to register active keys
val dragTarget = if (dragKey != null) activeDragTargets[dragKey] else null
dropTarget.hoverKey = dragTarget?.key
dropTarget.hoverData = dragTarget?.data
}
/** Returns true if drag target exists and is within bounds */
private fun DropTargetStateImpl<T>.hasValidDragTarget(): Boolean {
val currentKey = hoverKey ?: return false // no target
val dragTarget = activeDragTargets[currentKey] ?: return false // target not valid
return dragTarget.within(bounds)
}
/** Check if drag target fits in drop */
fun checkForDrop(dropKey: String) {
val dropTarget = dropTargets[dropKey] ?: return
val bounds = dropTarget.bounds
if (dropTarget.hasValidDragTarget()) return
// Find first target that matches
val dragKey = activeDragTargets.entries.firstOrNull { it.value.within(bounds) }?.key
setHover(dragKey = dragKey, dropKey = dropKey)
}
/** Check drops for drag target fit */
fun checkForDrag(dragKey: String) {
val dragTarget = activeDragTargets[dragKey] ?: return
for ((dropKey, dropTarget) in dropTargets) {
// Do not override targets that are valid
if (dropTarget.hasValidDragTarget()) continue
if (dragTarget.within(dropTarget.bounds)) {
setHover(dragKey = dragKey, dropKey = dropKey)
} else if (dropTarget.hoverKey == dragKey) {
setHover(dragKey = null, dropKey = dropKey)
}
}
}
}
private fun DragTargetStateImpl<*>?.within(bounds: Rect): Boolean {
if (this == null) return false
val center = dragPosition + Offset(size.width * 0.5f, size.height * 0.5f)
return bounds.contains(center)
}
/** State for individual dragging target. */
private class DragTargetStateImpl<T>(
override val key: String,
override val data: T,
val draggableState: DraggableStateImpl<T>,
override val dragComposable: @Composable () -> Unit,
) : DragTargetState<T> {
override var isDragging by mutableStateOf(false)
override var windowPosition = Offset.Zero
override var dragPosition by mutableStateOf(Offset.Zero)
override var size: IntSize by mutableStateOf(IntSize.Zero)
override fun onDragStart() {
// Another drag target is being used; ignore
dragPosition = windowPosition
if (key in draggableState.activeDragTargets) return
draggableState.activeDragTargets[key] = this
isDragging = true
}
override fun onDrag(offset: Offset) {
if (isDragging) {
dragPosition += offset
draggableState.checkForDrag(key)
}
}
override fun onDragEnd() {
if (!isDragging) return
draggableState.activeDragTargets.remove(key)
draggableState.cleanUpDrag(this)
isDragging = false
}
}
private class DropTargetStateImpl<T>(
private val key: String,
private val draggableState: DraggableStateImpl<T>
) : DropTargetState<T> {
var hoverKey: String? by mutableStateOf(null)
override var hoverData: T? by mutableStateOf(null)
override var bounds: Rect = Rect.Zero
set(value) {
field = value
draggableState.checkForDrop(key)
}
override val isHovered
get() = hoverKey != null
}

View File

@ -0,0 +1,80 @@
/*
* Copyright 2023 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.compose.effects
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.platform.inspectable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* State for tracking shaking animation
*
* Note: Used some other material states as reference. This however will recompose the function
* holding the state during shakes.
*/
@Composable
fun rememberShakeState(): ShakeState {
val scope = rememberCoroutineScope()
val state = remember(scope) { ShakeState(scope) }
return state
}
class ShakeState
internal constructor(
private val animationScope: CoroutineScope,
) {
private val rotationAnimatable = Animatable(0f)
internal val rotation
get() = rotationAnimatable.value
fun shake() {
animationScope.launch {
rotationAnimatable.stop()
rotationAnimatable.animateTo(
0f,
initialVelocity = 200f,
animationSpec =
spring(
dampingRatio = 0.3f,
stiffness = 200f,
),
)
}
}
}
fun Modifier.shake(state: ShakeState, enabled: Boolean = true) =
inspectable(
inspectorInfo =
debugInspectorInfo {
name = "shake"
properties["enabled"] = enabled
},
) {
Modifier.rotate(state.rotation)
}

View File

@ -0,0 +1,36 @@
/*
* Copyright 2023 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.ext
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import kotlin.math.roundToInt
@Composable
fun Modifier.thenIf(condition: Boolean, action: @Composable () -> Modifier): Modifier =
if (condition) then(action()) else this
fun Offset.toIntOffset() = IntOffset(x.roundToInt(), y.roundToInt())
fun IntSize.toDpSize(density: Density): DpSize {
return with(density) { DpSize(width.toDp(), height.toDp()) }
}

View File

@ -41,6 +41,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import com.pitchedapps.frost.R import com.pitchedapps.frost.R
import com.pitchedapps.frost.ext.WebTargetId import com.pitchedapps.frost.ext.WebTargetId
import com.pitchedapps.frost.main.MainTabItem import com.pitchedapps.frost.main.MainTabItem
import com.pitchedapps.frost.tabselector.TabData
import compose.icons.FontAwesomeIcons import compose.icons.FontAwesomeIcons
import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.Solid
import compose.icons.fontawesomeicons.solid.GlobeAmericas import compose.icons.fontawesomeicons.solid.GlobeAmericas
@ -127,6 +128,13 @@ fun FbItem.tab(context: Context, id: WebTargetId): MainTabItem =
url = url, url = url,
) )
fun FbItem.tab(context: Context): TabData =
TabData(
key = key,
title = context.getString(titleId),
icon = icon,
)
/// ** Note that this url only works if a query (?q=) is provided */ /// ** Note that this url only works if a query (?q=) is provided */
// _SEARCH("search", R.string.kau_search, GoogleMaterial.Icon.gmd_search, "search/top"), // _SEARCH("search", R.string.kau_search, GoogleMaterial.Icon.gmd_search, "search/top"),

View File

@ -23,10 +23,10 @@ import androidx.core.view.WindowCompat
import com.google.common.flogger.FluentLogger import com.google.common.flogger.FluentLogger
import com.pitchedapps.frost.R import com.pitchedapps.frost.R
import com.pitchedapps.frost.compose.FrostTheme import com.pitchedapps.frost.compose.FrostTheme
import com.pitchedapps.frost.tabselector.TabSelectorScreen
import com.pitchedapps.frost.web.state.FrostWebStore import com.pitchedapps.frost.web.state.FrostWebStore
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import mozilla.components.lib.state.ext.observeAsState
/** /**
* Main activity. * Main activity.
@ -52,11 +52,9 @@ class MainActivity : ComponentActivity() {
// tabs = tabs, // tabs = tabs,
// ) // )
val tabs = TabSelectorScreen()
store.observeAsState(initialValue = null) { it.homeTabs.map { it.tab } }.value
?: return@FrostTheme
MainScreenWebView(homeTabs = tabs) // MainScreenWebView()
} }
} }
} }

View File

@ -19,6 +19,7 @@ package com.pitchedapps.frost.main
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
@ -42,6 +43,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.pitchedapps.frost.compose.webview.FrostWebCompose import com.pitchedapps.frost.compose.webview.FrostWebCompose
import com.pitchedapps.frost.ext.WebTargetId import com.pitchedapps.frost.ext.WebTargetId
@ -52,9 +54,12 @@ import kotlinx.coroutines.launch
import mozilla.components.lib.state.ext.observeAsState import mozilla.components.lib.state.ext.observeAsState
@Composable @Composable
fun MainScreenWebView(modifier: Modifier = Modifier, homeTabs: List<MainTabItem>) { fun MainScreenWebView(modifier: Modifier = Modifier) {
val vm: MainScreenViewModel = viewModel() val vm: MainScreenViewModel = viewModel()
val homeTabs =
vm.store.observeAsState(initialValue = null) { it.homeTabs.map { it.tab } }.value ?: return
val selectedHomeTab by vm.store.observeAsState(initialValue = null) { it.selectedHomeTab } val selectedHomeTab by vm.store.observeAsState(initialValue = null) { it.selectedHomeTab }
Scaffold( Scaffold(
@ -95,7 +100,13 @@ fun MainBottomBar(
NavigationBar(modifier = modifier) { NavigationBar(modifier = modifier) {
items.forEach { item -> items.forEach { item ->
NavigationBarItem( NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.title) }, icon = {
Icon(
modifier = Modifier.size(24.dp),
imageVector = item.icon,
contentDescription = item.title,
)
},
selected = selectedTab == item.id, selected = selectedTab == item.id,
onClick = { onSelect(item.id) }, onClick = { onSelect(item.id) },
) )

View File

@ -0,0 +1,28 @@
/*
* Copyright 2023 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.tabselector
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.vector.ImageVector
/** Option for main screen tabs */
@Immutable
data class TabData(
val key: String,
val title: String,
val icon: ImageVector,
)

View File

@ -0,0 +1,263 @@
/*
* Copyright 2023 Allan Wang
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.pitchedapps.frost.tabselector
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ripple.LocalRippleTheme
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.pitchedapps.frost.compose.draggable.DragContainer
import com.pitchedapps.frost.compose.draggable.DraggableState
import com.pitchedapps.frost.compose.draggable.dragTarget
import com.pitchedapps.frost.compose.draggable.dropTarget
import com.pitchedapps.frost.compose.draggable.rememberDraggableState
import com.pitchedapps.frost.compose.effects.rememberShakeState
import com.pitchedapps.frost.compose.effects.shake
import com.pitchedapps.frost.facebook.FbItem
import com.pitchedapps.frost.facebook.tab
@Composable
fun TabSelectorScreen(modifier: Modifier = Modifier) {
val context = LocalContext.current
var selected: List<TabData> by remember {
mutableStateOf(FbItem.defaults().map { it.tab(context) })
}
val options: Map<String, TabData> = remember {
FbItem.values().associateBy({ it.key }, { it.tab(context) })
}
val unselected = remember(selected) { options.values - selected.toSet() }
TabSelector(
modifier = modifier,
selected = selected,
unselected = unselected,
onSelect = { selected = it },
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TabSelector(
modifier: Modifier,
selected: List<TabData>,
unselected: List<TabData>,
onSelect: (List<TabData>) -> Unit
) {
val draggableState =
rememberDraggableState<TabData>(
onDrop = { _, dragData, dropTarget ->
onSelect(
selected.map { if (it.key == dropTarget) dragData else it },
)
},
)
DragContainer(modifier = modifier, draggableState = draggableState) {
Column(modifier = Modifier.statusBarsPadding()) {
LazyVerticalGrid(
modifier = Modifier.weight(1f),
columns = GridCells.Fixed(4),
) {
items(unselected, key = { it.key }) { data ->
val dragTargetState =
draggableState.rememberDragTarget(key = data.key, data = data) {
DraggingTabItem(data = data)
}
val shakeState = rememberShakeState()
TabItem(
modifier =
Modifier.animateItemPlacement().dragTarget(dragTargetState).shake(shakeState),
data = data,
onClick = { shakeState.shake() },
)
}
}
TabBottomBar(
modifier = Modifier.navigationBarsPadding(),
draggableState = draggableState,
items = selected,
)
}
}
}
@Composable
fun TabBottomBar(
modifier: Modifier = Modifier,
draggableState: DraggableState<TabData>,
items: List<TabData>
) {
NavigationBar(modifier = modifier) {
items.forEach { item ->
val dropTargetState = draggableState.rememberDropTarget(item.key)
val alpha by
animateFloatAsState(
targetValue = if (!dropTargetState.isHovered) 1f else 0.3f,
label = "Nav Item Alpha",
)
NavigationBarItem(
modifier = Modifier.dropTarget(dropTargetState),
icon = {
val iconItem = dropTargetState.hoverData ?: item
Icon(
modifier = Modifier.size(24.dp).alpha(alpha),
imageVector = iconItem.icon,
contentDescription = iconItem.title,
)
},
selected = false,
onClick = {},
)
}
}
}
/**
* Our dragging tab item is fairly different from the original, so we'll just make a copy to add the
* animations.
*
* Animations are done as one shot events from a default
*/
@Composable
fun DraggingTabItem(
data: TabData,
modifier: Modifier = Modifier,
) {
var startState by remember { mutableStateOf(true) }
LaunchedEffect(Unit) { startState = false }
val transition = updateTransition(targetState = startState, label = "One Shot")
val scale by transition.animateFloat(label = "Scale") { if (it) 1f else 1.3f }
// Same color as ripple values
val color by
transition.animateColor(label = "Background") {
if (it)
MaterialTheme.colorScheme.surfaceVariant.copy(
LocalRippleTheme.current.rippleAlpha().pressedAlpha,
)
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f)
}
TabItem(
modifier =
modifier.graphicsLayer {
scaleX = scale
scaleY = scale
},
iconBackground = color,
labelAlpha = 0f,
data = data,
)
}
@Composable
fun TabItem(
data: TabData,
modifier: Modifier = Modifier,
iconBackground: Color = Color.Unspecified,
labelAlpha: Float = 1f,
onClick: () -> Unit = {}
) {
val interactionSource = remember { MutableInteractionSource() }
Column(
modifier
.clickable(onClick = onClick, interactionSource = interactionSource, indication = null)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
modifier =
Modifier.clip(CircleShape)
.background(iconBackground)
.indication(
interactionSource,
rememberRipple(color = MaterialTheme.colorScheme.surfaceVariant),
)
.padding(12.dp)
.size(24.dp),
imageVector = data.icon,
contentDescription = data.title,
)
Text(
// Weird offset is to accommodate the icon background, which is only used when the label is
// hidden
modifier = Modifier.offset(y = (-4).dp).alpha(labelAlpha),
text = data.title,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}