改包名com.aiosman.ravenow

This commit is contained in:
2024-11-17 20:07:42 +08:00
parent 914cfca6be
commit 074244c0f8
168 changed files with 897 additions and 970 deletions

View File

@@ -0,0 +1,141 @@
package com.aiosman.ravenow.ui.composables
//import androidx.compose.foundation.layout.ColumnScopeInstance.weight
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@Composable
fun ActionButton(
modifier: Modifier = Modifier,
text: String,
color: Color? = null,
backgroundColor: Color? = null,
leading: @Composable (() -> Unit)? = null,
expandText: Boolean = false,
contentPadding: PaddingValues = PaddingValues(vertical = 16.dp),
isLoading: Boolean = false,
loadingTextColor: Color? = null,
loadingText: String = "Loading",
loadingBackgroundColor: Color? = null,
disabledBackgroundColor: Color? = null,
enabled: Boolean = true,
fullWidth: Boolean = false,
roundCorner: Float = 24f,
fontSize: TextUnit = 17.sp,
fontWeight: FontWeight = FontWeight.W900,
click: () -> Unit = {}
) {
val AppColors = LocalAppTheme.current
val animatedBackgroundColor by animateColorAsState(
targetValue = run {
if (enabled) {
if (isLoading) {
loadingBackgroundColor ?: AppColors.loadingMain
} else {
backgroundColor ?: AppColors.basicMain
}
} else {
disabledBackgroundColor ?: AppColors.disabledBackground
}
},
animationSpec = tween(300), label = ""
)
Box(
modifier = modifier
.clip(RoundedCornerShape(roundCorner.dp))
.background(animatedBackgroundColor)
.noRippleClickable {
if (enabled && !isLoading) {
click()
}
}
.padding(contentPadding),
contentAlignment = Alignment.CenterStart
) {
if (!isLoading) {
Box(
modifier = Modifier
.align(Alignment.Center)
.let {
if (fullWidth) {
it.fillMaxWidth()
} else {
it
}
},
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
Box(modifier = Modifier.align(Alignment.CenterStart)) {
leading?.invoke()
}
}
Text(
text,
fontSize = fontSize,
color = color ?: AppColors.text,
fontWeight = fontWeight,
textAlign = if (expandText) TextAlign.Center else TextAlign.Start
)
}
} else {
Box(
modifier = Modifier
.let {
if (fullWidth) {
it.fillMaxWidth()
} else {
it
}
}
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = AppColors.text
)
Text(
loadingText,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = loadingTextColor ?: AppColors.loadingText,
)
}
}
}
}
}

View File

@@ -0,0 +1,41 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
@Composable
fun AnimatedCounter(count: Int, modifier: Modifier = Modifier, fontSize: Int = 24) {
val AppColors = LocalAppTheme.current
AnimatedContent(
targetState = count,
transitionSpec = {
// Compare the incoming number with the previous number.
if (targetState > initialState) {
// If the target number is larger, it slides up and fades in
// while the initial (smaller) number slides up and fades out.
(slideInVertically { height -> height } + fadeIn()).togetherWith(slideOutVertically { height -> -height } + fadeOut())
} else {
// If the target number is smaller, it slides down and fades in
// while the initial number slides down and fades out.
(slideInVertically { height -> -height } + fadeIn()).togetherWith(slideOutVertically { height -> height } + fadeOut())
}.using(
// Disable clipping since the faded slide-in/out should
// be displayed out of bounds.
SizeTransform(clip = false)
)
}
) { targetCount ->
Text(text = "$targetCount", modifier = modifier, fontSize = fontSize.sp, color = AppColors.text)
}
}

View File

@@ -0,0 +1,70 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import kotlinx.coroutines.launch
@Composable
fun AnimatedFavouriteIcon(
modifier: Modifier = Modifier,
isFavourite: Boolean = false,
onClick: (() -> Unit)? = null
) {
val AppColors = LocalAppTheme.current
val animatableRotation = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
suspend fun shake() {
repeat(2) {
animatableRotation.animateTo(
targetValue = 10f,
animationSpec = tween(100)
) {
}
animatableRotation.animateTo(
targetValue = -10f,
animationSpec = tween(100)
) {
}
}
animatableRotation.animateTo(
targetValue = 0f,
animationSpec = tween(100)
)
}
Box(contentAlignment = Alignment.Center, modifier = Modifier.noRippleClickable {
onClick?.invoke()
// Trigger shake animation
scope.launch {
shake()
}
}) {
Image(
painter = if (isFavourite) {
painterResource(id = R.drawable.rider_pro_favourited)
} else {
painterResource(id = R.drawable.rider_pro_favourite)
},
contentDescription = "Favourite",
modifier = modifier.graphicsLayer {
rotationZ = animatableRotation.value
},
colorFilter = ColorFilter.tint(AppColors.text)
)
}
}

View File

@@ -0,0 +1,69 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import kotlinx.coroutines.launch
@Composable
fun AnimatedLikeIcon(
modifier: Modifier = Modifier,
liked: Boolean = false,
onClick: (() -> Unit)? = null
) {
val AppColors = LocalAppTheme.current
val animatableRotation = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
suspend fun shake() {
repeat(2) {
animatableRotation.animateTo(
targetValue = 10f,
animationSpec = tween(100)
) {
}
animatableRotation.animateTo(
targetValue = -10f,
animationSpec = tween(100)
) {
}
}
animatableRotation.animateTo(
targetValue = 0f,
animationSpec = tween(100)
)
}
Box(contentAlignment = Alignment.Center, modifier = Modifier.noRippleClickable {
onClick?.invoke()
// Trigger shake animation
scope.launch {
shake()
}
}) {
Image(
painter = if (!liked) painterResource(id = R.drawable.rider_pro_moment_like) else painterResource(
id = R.drawable.rider_pro_moment_liked
),
contentDescription = "Like",
modifier = modifier.graphicsLayer {
rotationZ = animatableRotation.value
},
colorFilter = if (!liked) ColorFilter.tint(AppColors.text) else null
)
}
}

View File

@@ -0,0 +1,76 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.drawable.toDrawable
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.aiosman.ravenow.utils.BlurHashDecoder
import com.aiosman.ravenow.utils.Utils.getImageLoader
const val DEFAULT_HASHED_BITMAP_WIDTH = 4
const val DEFAULT_HASHED_BITMAP_HEIGHT = 3
/**
* This function is used to load an image asynchronously and blur it using BlurHash.
* @param imageUrl The URL of the image to be loaded.
* @param modifier The modifier to be applied to the image.
* @param imageModifier The modifier to be applied to the image.
* @param contentDescription The content description to be applied to the image.
* @param contentScale The content scale to be applied to the image.
* @param isCrossFadeRequired Whether cross-fade is required or not.
* @param onImageLoadSuccess The callback to be called when the image is loaded successfully.
* @param onImageLoadFailure The callback to be called when the image is failed to load.
* @see AsyncImage
*/
@Suppress("LongParameterList")
@ExperimentalCoilApi
@Composable
fun AsyncBlurImage(
imageUrl: String,
blurHash: String,
modifier: Modifier = Modifier,
imageModifier: Modifier? = null,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit,
isCrossFadeRequired: Boolean = false,
onImageLoadSuccess: () -> Unit = {},
onImageLoadFailure: () -> Unit = {}
) {
val context = LocalContext.current
val resources = context.resources
val imageLoader = getImageLoader(context)
val blurBitmap by remember(blurHash) {
mutableStateOf(
BlurHashDecoder.decode(
blurHash = blurHash,
width = DEFAULT_HASHED_BITMAP_WIDTH,
height = DEFAULT_HASHED_BITMAP_HEIGHT
)
)
}
AsyncImage(
modifier = imageModifier ?: modifier,
model = ImageRequest.Builder(context)
.data(imageUrl)
.crossfade(isCrossFadeRequired)
.placeholder(
blurBitmap?.toDrawable(resources)
)
.fallback(blurBitmap?.toDrawable(resources))
.build(),
contentDescription = contentDescription,
contentScale = contentScale,
onSuccess = { onImageLoadSuccess() },
onError = { onImageLoadFailure() },
imageLoader = imageLoader
)
}

View File

@@ -0,0 +1,24 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
@Composable
fun BottomNavigationPlaceholder(
color: Color? = null
) {
val navigationBarHeight = with(LocalDensity.current) {
WindowInsets.navigationBars.getBottom(this).toDp()
}
Box(
modifier = Modifier.height(navigationBarHeight).fillMaxWidth().background(color ?: Color.Transparent)
)
}

View File

@@ -0,0 +1,54 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@Composable
fun Checkbox(
size: Int = 24,
checked: Boolean = false,
onCheckedChange: (Boolean) -> Unit = {}
) {
val AppColors = LocalAppTheme.current
val backgroundColor by animateColorAsState(if (checked) AppColors.checkedBackground else Color.Transparent)
val borderColor by animateColorAsState(if (checked) Color.Transparent else AppColors.secondaryText)
val borderWidth by animateDpAsState(if (checked) 0.dp else 2.dp)
Box(
modifier = Modifier
.size(size.dp)
.noRippleClickable {
onCheckedChange(!checked)
}
.clip(CircleShape)
.background(color = backgroundColor)
.border(width = borderWidth, color = borderColor, shape = CircleShape)
.padding(2.dp)
) {
if (checked) {
Icon(
Icons.Default.Check,
contentDescription = "Checked",
tint = AppColors.checkedText
)
}
}
}

View File

@@ -0,0 +1,41 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
@Composable
fun CheckboxWithLabel(
checked: Boolean = false,
checkSize: Int = 16,
label: String = "",
fontSize: Int = 12,
error: Boolean = false,
onCheckedChange: (Boolean) -> Unit,
) {
val AppColors = LocalAppTheme.current
Row(
) {
Checkbox(
checked = checked,
onCheckedChange = {
onCheckedChange(it)
},
size = checkSize
)
Text(
text = label,
modifier = Modifier.padding(start = 8.dp),
fontSize = fontSize.sp,
style = TextStyle(
color = if (error) AppColors.error else AppColors.text
)
)
}
}

View File

@@ -0,0 +1,186 @@
package com.aiosman.ravenow.ui.composables
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.AlertDialog
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.api.CaptchaResponseBody
import java.io.ByteArrayInputStream
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ClickCaptchaView(
captchaData: CaptchaResponseBody,
onPositionClicked: (Offset) -> Unit
) {
var clickPositions by remember { mutableStateOf(listOf<Offset>()) }
val context = LocalContext.current
val imageBitmap = remember(captchaData.masterBase64) {
val decodedString = Base64.decode(captchaData.masterBase64, Base64.DEFAULT)
val inputStream = ByteArrayInputStream(decodedString)
BitmapFactory.decodeStream(inputStream).asImageBitmap()
}
val thumbnailBitmap = remember(captchaData.thumbBase64) {
val decodedString = Base64.decode(captchaData.thumbBase64, Base64.DEFAULT)
val inputStream = ByteArrayInputStream(decodedString)
BitmapFactory.decodeStream(inputStream).asImageBitmap()
}
var boxWidth by remember { mutableStateOf(0) }
var boxHeightInDp by remember { mutableStateOf(0.dp) }
var scale by remember { mutableStateOf(1f) }
val density = LocalDensity.current
Column(
modifier = Modifier
.fillMaxWidth()
) {
Text(stringResource(R.string.captcha_hint))
Spacer(modifier = Modifier.height(16.dp))
Image(
bitmap = thumbnailBitmap,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned {
boxWidth = it.size.width
scale = imageBitmap.width.toFloat() / boxWidth
boxHeightInDp = with(density) { (imageBitmap.height.toFloat() / scale).toDp() }
}
.background(Color.Gray)
) {
if (boxWidth != 0 && boxHeightInDp != 0.dp) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(boxHeightInDp)
) {
Image(
bitmap = imageBitmap,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.pointerInteropFilter { event ->
if (event.action == android.view.MotionEvent.ACTION_DOWN) {
val newPosition = Offset(event.x, event.y)
clickPositions = clickPositions + newPosition
// 计算出点击的位置在图片上的坐标
val imagePosition = Offset(
newPosition.x * scale,
newPosition.y * scale
)
onPositionClicked(imagePosition)
true
} else {
false
}
}
)
// Draw markers at click positions
clickPositions.forEachIndexed { index, position ->
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = Color(0xaada3832).copy(),
radius = 40f,
center = position
)
drawContext.canvas.nativeCanvas.apply {
drawText(
(index + 1).toString(),
position.x,
position.y + 15f, // Adjusting the y position to center the text
android.graphics.Paint().apply {
color = android.graphics.Color.WHITE
textSize = 50f
textAlign = android.graphics.Paint.Align.CENTER
}
)
}
}
}
}
}
}
}
}
@Composable
fun ClickCaptchaDialog(
captchaData: CaptchaResponseBody,
onLoadCaptcha: () -> Unit,
onDismissRequest: () -> Unit,
onPositionClicked: (Offset) -> Unit
) {
AlertDialog(
onDismissRequest = {
onDismissRequest()
},
title = {
Text(stringResource(R.string.captcha))
},
text = {
Column {
Box(
modifier = Modifier
.fillMaxWidth()
) {
ClickCaptchaView(
captchaData = captchaData,
onPositionClicked = onPositionClicked
)
}
Spacer(modifier = Modifier.height(16.dp))
ActionButton(
text = stringResource(R.string.refresh),
modifier = Modifier
.fillMaxWidth(),
) {
onLoadCaptcha()
}
}
},
confirmButton = {
},
)
}

View File

@@ -0,0 +1,50 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
@Composable
fun CustomClickableText(
text: AnnotatedString,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
onLongPress: () -> Unit = {},
onClick: (Int) -> Unit
) {
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(onClick) {
detectTapGestures(
onLongPress = { onLongPress() }
) { pos ->
layoutResult.value?.let { layoutResult ->
onClick(layoutResult.getOffsetForPosition(pos))
}
}
}
BasicText(
text = text,
modifier = modifier.then(pressIndicator),
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
onTextLayout = {
layoutResult.value = it
onTextLayout(it)
}
)
}

View File

@@ -0,0 +1,282 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
import androidx.compose.foundation.lazy.grid.LazyGridItemScope
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T : Any> DraggableGrid(
items: List<T>,
getItemId: (T) -> String,
onMove: (Int, Int) -> Unit,
onDragModeStart: () -> Unit, // New parameter for drag start
onDragModeEnd: () -> Unit, // New parameter for drag end,
additionalItems: List<@Composable () -> Unit> = emptyList(), // New parameter for additional items
lockedIndices: List<Int> = emptyList(), // New parameter for locked indices
content: @Composable (T, Boolean) -> Unit,
) {
val gridState = rememberLazyGridState()
val dragDropState =
rememberGridDragDropState(gridState, onMove, onDragModeStart, onDragModeEnd, lockedIndices)
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.dragContainer(dragDropState),
state = gridState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
itemsIndexed(items, key = { _, item ->
getItemId(item)
}) { index, item ->
DraggableItem(dragDropState, index) { isDragging ->
content(item, isDragging)
}
}
additionalItems.forEach { additionalItem ->
item {
additionalItem()
}
}
}
}
fun Modifier.dragContainer(dragDropState: GridDragDropState): Modifier {
return pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset = offset)
},
onDragStart = { offset -> dragDropState.onDragStart(offset) },
onDragEnd = { dragDropState.onDragInterrupted() },
onDragCancel = { dragDropState.onDragInterrupted() }
)
}
}
@ExperimentalFoundationApi
@Composable
fun LazyGridItemScope.DraggableItem(
dragDropState: GridDragDropState,
index: Int,
modifier: Modifier = Modifier,
content: @Composable (isDragging: Boolean) -> Unit,
) {
val dragging = index == dragDropState.draggingItemIndex
val draggingModifier = if (dragging) {
Modifier
.zIndex(1f)
.graphicsLayer {
translationX = dragDropState.draggingItemOffset.x
translationY = dragDropState.draggingItemOffset.y
}
} else if (index == dragDropState.previousIndexOfDraggedItem) {
Modifier
.zIndex(1f)
.graphicsLayer {
translationX = dragDropState.previousItemOffset.value.x
translationY = dragDropState.previousItemOffset.value.y
}
} else {
Modifier.animateItemPlacement()
}
Box(modifier = modifier.then(draggingModifier), propagateMinConstraints = true) {
content(dragging)
}
}
@Composable
fun rememberGridDragDropState(
gridState: LazyGridState,
onMove: (Int, Int) -> Unit,
onDragModeStart: () -> Unit,
onDragModeEnd: () -> Unit,
lockedIndices: List<Int> // New parameter for locked indices
): GridDragDropState {
val scope = rememberCoroutineScope()
val state = remember(gridState) {
GridDragDropState(
state = gridState,
onMove = onMove,
scope = scope,
onDragModeStart = onDragModeStart,
onDragModeEnd = onDragModeEnd,
lockedIndices = lockedIndices // Pass the locked indices
)
}
LaunchedEffect(state) {
while (true) {
val diff = state.scrollChannel.receive()
gridState.scrollBy(diff)
}
}
return state
}
class GridDragDropState internal constructor(
private val state: LazyGridState,
private val scope: CoroutineScope,
private val onMove: (Int, Int) -> Unit,
private val onDragModeStart: () -> Unit,
private val onDragModeEnd: () -> Unit,
private val lockedIndices: List<Int> // New parameter for locked indices
) {
var draggingItemIndex by mutableStateOf<Int?>(null)
private set
internal val scrollChannel = Channel<Float>()
private var draggingItemDraggedDelta by mutableStateOf(Offset.Zero)
private var draggingItemInitialOffset by mutableStateOf(Offset.Zero)
internal val draggingItemOffset: Offset
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset.toOffset()
} ?: Offset.Zero
private val draggingItemLayoutInfo: LazyGridItemInfo?
get() = state.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == draggingItemIndex }
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set
internal var previousItemOffset = Animatable(Offset.Zero, Offset.VectorConverter)
private set
internal fun onDragStart(offset: Offset) {
state.layoutInfo.visibleItemsInfo
.firstOrNull { item ->
offset.x.toInt() in item.offset.x..item.offsetEnd.x &&
offset.y.toInt() in item.offset.y..item.offsetEnd.y
}?.also {
if (it.index !in lockedIndices) { // Check if the item is not locked
draggingItemIndex = it.index
draggingItemInitialOffset = it.offset.toOffset()
onDragModeStart() // Notify drag start
}
}
}
internal fun onDragInterrupted() {
if (draggingItemIndex != null) {
previousIndexOfDraggedItem = draggingItemIndex
val startOffset = draggingItemOffset
scope.launch {
previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
Offset.Zero,
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = Offset.VisibilityThreshold
)
)
previousIndexOfDraggedItem = null
}
}
draggingItemDraggedDelta = Offset.Zero
draggingItemIndex = null
draggingItemInitialOffset = Offset.Zero
onDragModeEnd() // Notify drag end
}
internal fun onDrag(offset: Offset) {
draggingItemDraggedDelta += offset
val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset.toOffset() + draggingItemOffset
val endOffset = startOffset + draggingItem.size.toSize()
val middleOffset = startOffset + (endOffset - startOffset) / 2f
val targetItem = state.layoutInfo.visibleItemsInfo.find { item ->
middleOffset.x.toInt() in item.offset.x..item.offsetEnd.x &&
middleOffset.y.toInt() in item.offset.y..item.offsetEnd.y &&
draggingItem.index != item.index &&
item.index !in lockedIndices // Check if the target item is not locked
}
if (targetItem != null) {
val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) {
draggingItem.index
} else if (draggingItem.index == state.firstVisibleItemIndex) {
targetItem.index
} else {
null
}
if (scrollToIndex != null) {
scope.launch {
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
onMove.invoke(draggingItem.index, targetItem.index)
}
} else {
onMove.invoke(draggingItem.index, targetItem.index)
}
draggingItemIndex = targetItem.index
} else {
val overscroll = when {
draggingItemDraggedDelta.y > 0 ->
(endOffset.y - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
draggingItemDraggedDelta.y < 0 ->
(startOffset.y - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
}
if (overscroll != 0f) {
scrollChannel.trySend(overscroll)
}
}
}
private val LazyGridItemInfo.offsetEnd: IntOffset
get() = this.offset + this.size
}
operator fun IntOffset.plus(size: IntSize): IntOffset {
return IntOffset(x + size.width, y + size.height)
}
operator fun Offset.plus(size: Size): Offset {
return Offset(x + size.width, y + size.height)
}

View File

@@ -0,0 +1,87 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
data class MenuItem(
val title: String,
val icon: Int,
val action: () -> Unit
)
@Composable
fun DropdownMenu(
expanded: Boolean = false,
menuItems: List<MenuItem> = emptyList(),
width: Int? = null,
onDismissRequest: () -> Unit = {},
) {
val AppColors = LocalAppTheme.current
MaterialTheme(
shapes = MaterialTheme.shapes.copy(
extraSmall = RoundedCornerShape(
16.dp
)
)
) {
androidx.compose.material3.DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
modifier = Modifier
.let {
if (width != null) it.width(width.dp) else it
}
.background(AppColors.background)
) {
for (item in menuItems) {
Box(
modifier = Modifier
.padding(vertical = 14.dp, horizontal = 24.dp)
.noRippleClickable {
item.action()
}) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
item.title,
fontWeight = FontWeight.W500,
color = AppColors.text,
)
if (width != null) {
Spacer(modifier = Modifier.weight(1f))
} else {
Spacer(modifier = Modifier.width(16.dp))
}
Icon(
painter = painterResource(id = item.icon),
contentDescription = "",
modifier = Modifier.size(24.dp),
tint = AppColors.text
)
}
}
}
}
}
}

View File

@@ -0,0 +1,171 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.Icon
import androidx.compose.material.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.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.CommentEntity
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@Composable
fun EditCommentBottomModal(
replyComment: CommentEntity? = null,
onSend: (String) -> Unit = {}
) {
val AppColors = LocalAppTheme.current
var text by remember { mutableStateOf("") }
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val focusRequester = remember { FocusRequester() }
val context = LocalContext.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(AppColors.background)
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
if (replyComment == null) "Comment" else "Reply",
fontWeight = FontWeight.W600,
modifier = Modifier.weight(1f),
fontSize = 20.sp,
fontStyle = FontStyle.Italic,
color = AppColors.text
)
Crossfade(
targetState = text.isNotEmpty(), animationSpec = tween(500),
label = ""
) { isNotEmpty ->
Icon(
painter = painterResource(id = R.drawable.rider_pro_video_share),
contentDescription = "Emoji",
modifier = Modifier
.size(32.dp)
.noRippleClickable {
if (text.isNotEmpty()) {
onSend(text)
text = ""
}
},
tint = if (isNotEmpty) AppColors.main else AppColors.nonActive
)
}
}
Spacer(modifier = Modifier.height(16.dp))
if (replyComment != null) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
) {
CustomAsyncImage(
context,
replyComment.avatar,
modifier = Modifier.fillMaxSize(),
contentDescription = "Avatar",
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
replyComment.name,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = AppColors.text
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
replyComment.comment,
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.padding(start = 32.dp),
overflow = TextOverflow.Ellipsis,
color = AppColors.text
)
Spacer(modifier = Modifier.height(16.dp))
}
Row(
modifier = Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clip(RoundedCornerShape(20.dp))
.background(AppColors.inputBackground)
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
BasicTextField(
value = text,
onValueChange = {
text = it
},
cursorBrush = SolidColor(AppColors.text),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
textStyle = TextStyle(
color = AppColors.text,
fontWeight = FontWeight.Normal
),
minLines = 5
)
}
}
Spacer(modifier = Modifier.height(navBarHeight))
}
}

View File

@@ -0,0 +1,52 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@Composable
fun FollowButton(
isFollowing: Boolean,
fontSize: TextUnit = 12.sp,
onFollowClick: () -> Unit,
){
val AppColors = LocalAppTheme.current
Box(
modifier = Modifier
.wrapContentWidth()
.clip(RoundedCornerShape(8.dp))
.background(
color = if (isFollowing) AppColors.main else AppColors.nonActive
)
.padding(horizontal = 16.dp, vertical = 8.dp)
.noRippleClickable {
onFollowClick()
},
contentAlignment = Alignment.Center
) {
Text(
text = if (isFollowing) stringResource(R.string.following_upper) else stringResource(
R.string.follow_upper
),
fontSize = fontSize,
color = if (isFollowing) AppColors.mainText else AppColors.nonActiveText,
style = TextStyle(fontWeight = FontWeight.Bold)
)
}
}

View File

@@ -0,0 +1,70 @@
package com.aiosman.ravenow.ui.composables
import android.content.Context
import android.graphics.Bitmap
import androidx.annotation.DrawableRes
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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.request.ImageRequest
import coil.request.SuccessResult
import com.aiosman.ravenow.utils.Utils.getImageLoader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun rememberImageBitmap(imageUrl: String, imageLoader: ImageLoader): Bitmap? {
val context = LocalContext.current
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(imageUrl) {
val request = ImageRequest.Builder(context)
.data(imageUrl)
.crossfade(true)
.build()
val result = withContext(Dispatchers.IO) {
(imageLoader.execute(request) as? SuccessResult)?.drawable?.toBitmap()
}
bitmap = result
}
return bitmap
}
@Composable
fun CustomAsyncImage(
context: Context? = null,
imageUrl: Any?,
contentDescription: String?,
modifier: Modifier = Modifier,
blurHash: String? = null,
@DrawableRes
placeholderRes: Int? = null,
contentScale: ContentScale = ContentScale.Crop
) {
val localContext = LocalContext.current
val imageLoader = getImageLoader(context ?: localContext)
AsyncImage(
model = ImageRequest.Builder(context ?: localContext)
.data(imageUrl)
.crossfade(200)
.build(),
contentDescription = contentDescription,
modifier = modifier,
contentScale = contentScale,
imageLoader = imageLoader
)
}

View File

@@ -0,0 +1,540 @@
package com.aiosman.ravenow.ui.composables
import androidx.annotation.DrawableRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.foundation.layout.wrapContentWidth
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.entity.MomentImageEntity
import com.aiosman.ravenow.exp.timeAgo
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.comment.CommentModalContent
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToPost
@Composable
fun MomentCard(
momentEntity: MomentEntity,
onLikeClick: () -> Unit = {},
onFavoriteClick: () -> Unit = {},
onAddComment: () -> Unit = {},
onFollowClick: () -> Unit = {},
hideAction: Boolean = false,
showFollowButton: Boolean = true
) {
val AppColors = LocalAppTheme.current
var imageIndex by remember { mutableStateOf(0) }
val navController = LocalNavController.current
Column(
modifier = Modifier
.fillMaxWidth()
.background(AppColors.background)
) {
Box(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
) {
MomentTopRowGroup(
momentEntity = momentEntity,
onFollowClick = onFollowClick,
showFollowButton = showFollowButton
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.noRippleClickable {
navController.navigateToPost(
momentEntity.id,
highlightCommentId = 0,
initImagePagerIndex = imageIndex
)
}
) {
MomentContentGroup(
momentEntity = momentEntity,
onPageChange = { index -> imageIndex = index }
)
}
if (!hideAction) {
MomentBottomOperateRowGroup(
momentEntity = momentEntity,
onLikeClick = onLikeClick,
onAddComment = onAddComment,
onFavoriteClick = onFavoriteClick,
imageIndex = imageIndex,
onCommentClick = {
navController.navigateToPost(
momentEntity.id,
highlightCommentId = 0,
initImagePagerIndex = imageIndex
)
}
)
}
}
}
@Composable
fun ModificationListHeader() {
val navController = LocalNavController.current
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFF8F8F8))
.padding(4.dp)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
navController.navigate("ModificationList")
}
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.background(Color(0xFFEB4869))
.padding(8.dp)
) {
Icon(
Icons.Filled.Build,
contentDescription = "Modification Icon",
tint = Color.White, // Assuming the icon should be white
modifier = Modifier.size(12.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Modification List",
color = Color(0xFF333333),
fontSize = 14.sp,
textAlign = TextAlign.Left
)
}
}
}
}
@Composable
fun MomentName(name: String, modifier: Modifier = Modifier) {
val AppColors = LocalAppTheme.current
Text(
modifier = modifier,
textAlign = TextAlign.Start,
text = name,
color = AppColors.text,
fontSize = 16.sp, style = TextStyle(fontWeight = FontWeight.Bold)
)
}
@Composable
fun MomentFollowBtn() {
Box(
modifier = Modifier
.size(width = 53.dp, height = 18.dp)
.padding(start = 8.dp),
contentAlignment = Alignment.Center
) {
Image(
modifier = Modifier
.fillMaxSize(),
painter = painterResource(id = R.drawable.follow_bg),
contentDescription = ""
)
Text(
text = "Follow",
color = Color.White,
fontSize = 12.sp
)
}
}
@Composable
fun MomentPostLocation(location: String) {
val AppColors = LocalAppTheme.current
Text(
text = location,
color = AppColors.secondaryText,
fontSize = 12.sp
)
}
@Composable
fun MomentPostTime(time: String) {
val AppColors = LocalAppTheme.current
Text(
modifier = Modifier,
text = time, color = AppColors.text,
fontSize = 12.sp
)
}
@Composable
fun MomentTopRowGroup(
momentEntity: MomentEntity,
showFollowButton: Boolean = true,
onFollowClick: () -> Unit = {}
) {
val navController = LocalNavController.current
val context = LocalContext.current
Row(
modifier = Modifier
) {
CustomAsyncImage(
context,
momentEntity.avatar,
contentDescription = "",
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(40.dp))
.noRippleClickable {
navController.navigate(
NavigationRoute.AccountProfile.route.replace(
"{id}",
momentEntity.authorId.toString()
)
)
},
contentScale = ContentScale.Crop
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 12.dp, end = 12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(22.dp),
verticalAlignment = Alignment.CenterVertically
) {
MomentName(
modifier = Modifier.weight(1f),
name = momentEntity.nickname
)
Spacer(modifier = Modifier.width(16.dp))
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(21.dp),
verticalAlignment = Alignment.CenterVertically
) {
MomentPostTime(momentEntity.time.timeAgo(context))
Spacer(modifier = Modifier.width(8.dp))
MomentPostLocation(momentEntity.location)
}
}
val isFollowing = momentEntity.followStatus
if (showFollowButton && !isFollowing) {
Spacer(modifier = Modifier.width(16.dp))
if (AppState.UserId != momentEntity.authorId) {
FollowButton(
isFollowing = false
) {
onFollowClick()
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PostImageView(
images: List<MomentImageEntity>,
onPageChange: (Int) -> Unit = {}
) {
val pagerState = rememberPagerState(pageCount = { images.size })
LaunchedEffect(pagerState.currentPage) {
onPageChange(pagerState.currentPage)
}
val context = LocalContext.current
Column(
modifier = Modifier.fillMaxWidth()
) {
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
) { page ->
val image = images[page]
CustomAsyncImage(
context,
image.thumbnail,
contentDescription = "Image",
blurHash = image.blurHash,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
)
}
}
}
@Composable
fun MomentContentGroup(
momentEntity: MomentEntity,
onPageChange: (Int) -> Unit = {}
) {
val AppColors = LocalAppTheme.current
if (momentEntity.momentTextContent.isNotEmpty()) {
Text(
text = momentEntity.momentTextContent,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
fontSize = 16.sp,
color = AppColors.text
)
}
if (momentEntity.relMoment != null) {
RelPostCard(
momentEntity = momentEntity.relMoment!!,
modifier = Modifier.background(Color(0xFFF8F8F8))
)
} else {
Box(
modifier = Modifier.fillMaxWidth()
) {
PostImageView(
images = momentEntity.images,
onPageChange = onPageChange
)
}
}
}
@Composable
fun MomentOperateBtn(@DrawableRes icon: Int, count: String) {
val AppColors = LocalAppTheme.current
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
modifier = Modifier
.size(width = 24.dp, height = 24.dp),
painter = painterResource(id = icon),
contentDescription = "",
colorFilter = ColorFilter.tint(AppColors.text)
)
Text(
text = count,
modifier = Modifier.padding(start = 7.dp),
fontSize = 12.sp,
color = AppColors.text
)
}
}
@Composable
fun MomentOperateBtn(count: String, content: @Composable () -> Unit) {
Row(
modifier = Modifier,
verticalAlignment = Alignment.CenterVertically
) {
content()
AnimatedCounter(
count = count.toInt(),
fontSize = 14,
modifier = Modifier
.padding(start = 7.dp)
.width(24.dp)
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MomentBottomOperateRowGroup(
onLikeClick: () -> Unit = {},
onAddComment: () -> Unit = {},
onCommentClick: () -> Unit = {},
onFavoriteClick: () -> Unit = {},
momentEntity: MomentEntity,
imageIndex: Int = 0
) {
var showCommentModal by remember { mutableStateOf(false) }
if (showCommentModal) {
ModalBottomSheet(
onDismissRequest = { showCommentModal = false },
containerColor = Color.White,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
),
windowInsets = WindowInsets(0),
dragHandle = {
Box(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.clip(CircleShape)
) {
}
}
) {
CommentModalContent(
postId = momentEntity.id,
commentCount = momentEntity.commentCount,
onCommentAdded = {
onAddComment()
}
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.padding(start = 16.dp, end = 0.dp)
) {
Row(
modifier = Modifier.fillMaxSize()
) {
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
MomentOperateBtn(count = momentEntity.likeCount.toString()) {
AnimatedLikeIcon(
modifier = Modifier.size(24.dp),
liked = momentEntity.liked
) {
onLikeClick()
}
}
}
Spacer(modifier = Modifier.width(4.dp))
Box(
modifier = Modifier
.fillMaxHeight()
.noRippleClickable {
onCommentClick()
},
contentAlignment = Alignment.Center
) {
MomentOperateBtn(
icon = R.drawable.rider_pro_comment,
count = momentEntity.commentCount.toString()
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.noRippleClickable {
onFavoriteClick()
},
contentAlignment = Alignment.CenterEnd
) {
MomentOperateBtn(count = momentEntity.favoriteCount.toString()) {
AnimatedFavouriteIcon(
modifier = Modifier.size(24.dp),
isFavourite = momentEntity.isFavorite
) {
onFavoriteClick()
}
}
}
}
if (momentEntity.images.size > 1) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
momentEntity.images.forEachIndexed { index, _ ->
Box(
modifier = Modifier
.size(4.dp)
.clip(CircleShape)
.background(
if (imageIndex == index) Color.Red else Color.Gray.copy(
alpha = 0.5f
)
)
.padding(1.dp)
)
Spacer(modifier = Modifier.width(8.dp))
}
}
}
}
}
@Composable
fun MomentListLoading() {
CircularProgressIndicator(
modifier =
Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally),
color = Color.Red
)
}

View File

@@ -0,0 +1,38 @@
package com.aiosman.ravenow.ui.composables
import android.app.Activity
import android.content.Context
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import com.aiosman.ravenow.utils.Utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.File
/**
* 选择图片并压缩
*/
@Composable
fun pickupAndCompressLauncher(
context: Context,
scope: CoroutineScope,
maxSize: Int = 512,
quality: Int = 85,
onImagePicked: (Uri, File) -> Unit
) = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val uri = result.data?.data
uri?.let {
scope.launch {
// Compress the image
val file = Utils.compressImage(context, it, maxSize = maxSize, quality = quality)
// Check the compressed image size
onImagePicked(it, file)
}
}
}
}

View File

@@ -0,0 +1,153 @@
package com.aiosman.ravenow.ui.composables
import android.net.http.SslError
import android.webkit.SslErrorHandler
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.runtime.Composable
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.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import com.aiosman.ravenow.ConstVars
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.DictService
import com.aiosman.ravenow.data.DictServiceImpl
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PolicyCheckbox(
checked: Boolean = false,
error: Boolean = false,
onCheckedChange: (Boolean) -> Unit,
) {
var showModal by remember { mutableStateOf(false) }
var modalSheetState = androidx.compose.material3.rememberModalBottomSheetState(
skipPartiallyExpanded = true,
)
var scope = rememberCoroutineScope()
val dictService: DictService = DictServiceImpl()
var policyUrl by remember { mutableStateOf("") }
val appColor = LocalAppTheme.current
fun openPolicyModel() {
scope.launch {
try {
val resp = dictService.getDictByKey(ConstVars.DICT_KEY_PRIVATE_POLICY_URL)
policyUrl = resp.value
showModal = true
} catch (e: Exception) {
e.printStackTrace()
}
}
}
if (showModal) {
ModalBottomSheet(
onDismissRequest = {
showModal = false
},
sheetState = modalSheetState,
windowInsets = WindowInsets(0),
containerColor = Color.White,
) {
WebViewDisplay(
url = policyUrl
)
}
}
Row {
Checkbox(
checked = checked,
onCheckedChange = {
onCheckedChange(it)
},
size = 16
)
val text = buildAnnotatedString {
val keyword = stringResource(R.string.private_policy_keyword)
val template = stringResource(R.string.private_policy_template)
append(template)
append(" ")
withStyle(style = SpanStyle(color = if (error) appColor.error else appColor.text)) {
append(keyword)
}
addStyle(
style = SpanStyle(
color = appColor.main,
textDecoration = TextDecoration.Underline
),
start = template.length + 1,
end = template.length + keyword.length + 1
)
append(".")
}
ClickableText(
text = text,
modifier = Modifier.padding(start = 8.dp),
onClick = {
openPolicyModel()
},
style = TextStyle(
fontSize = 12.sp,
color = if (error) appColor.error else appColor.text
)
)
}
}
@Composable
fun WebViewDisplay(modifier: Modifier = Modifier, url: String) {
LazyColumn(
modifier = modifier.fillMaxSize()
) {
item {
AndroidView(
factory = { context ->
WebView(context).apply {
webViewClient = object : WebViewClient() {
override fun onReceivedSslError(
view: WebView?,
handler: SslErrorHandler?,
error: SslError?
) {
handler?.proceed() // 忽略证书错误
}
}
settings.apply {
domStorageEnabled = true
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
}
loadUrl(url)
}
},
modifier = modifier.fillMaxSize()
)
}
}
}

View File

@@ -0,0 +1,39 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.entity.MomentEntity
@Composable
fun RelPostCard(
momentEntity: MomentEntity,
modifier: Modifier = Modifier,
) {
val image = momentEntity.images.firstOrNull()
val context = LocalContext.current
Column(
modifier = modifier
) {
MomentTopRowGroup(momentEntity = momentEntity)
Box(
modifier=Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) {
image?.let {
CustomAsyncImage(
context,
image.thumbnail,
contentDescription = null,
modifier = Modifier.size(100.dp),
contentScale = ContentScale.Crop
)
}
}
}
}

View File

@@ -0,0 +1,69 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.systemBars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.LocalAppTheme
import com.google.accompanist.systemuicontroller.rememberSystemUiController
@Composable
fun StatusBarMask(darkIcons: Boolean = true) {
val paddingValues = WindowInsets.systemBars.asPaddingValues()
val systemUiController = rememberSystemUiController()
LaunchedEffect(Unit) {
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = darkIcons)
}
Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding()))
}
@Composable
fun StatusBarMaskLayout(
modifier: Modifier = Modifier,
darkIcons: Boolean = true,
useNavigationBarMask: Boolean = true,
maskBoxBackgroundColor: Color = Color.Transparent,
content: @Composable ColumnScope.() -> Unit
) {
val AppColors = LocalAppTheme.current
val paddingValues = WindowInsets.systemBars.asPaddingValues()
val systemUiController = rememberSystemUiController()
val navigationBarPaddings =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
LaunchedEffect(Unit) {
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = darkIcons)
}
Column(
modifier = modifier.fillMaxSize()
) {
Box(
modifier = Modifier
.height(paddingValues.calculateTopPadding())
.fillMaxWidth()
.background(maskBoxBackgroundColor)
) {
}
content()
if (navigationBarPaddings > 24.dp && useNavigationBarMask) {
Box(
modifier = Modifier
.height(navigationBarPaddings).fillMaxWidth().background(AppColors.background)
)
}
}
}

View File

@@ -0,0 +1,15 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.systemBars
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun StatusBarSpacer() {
val paddingValues = WindowInsets.systemBars.asPaddingValues()
Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding()))
}

View File

@@ -0,0 +1,145 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@Composable
fun TextInputField(
modifier: Modifier = Modifier,
text: String,
onValueChange: (String) -> Unit,
password: Boolean = false,
label: String? = null,
hint: String? = null,
error: String? = null,
enabled: Boolean = true
) {
val AppColors = LocalAppTheme.current
var showPassword by remember { mutableStateOf(!password) }
var isFocused by remember { mutableStateOf(false) }
Column(modifier = modifier) {
label?.let {
Text(it, color = AppColors.secondaryText)
Spacer(modifier = Modifier.height(16.dp))
}
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier
.clip(RoundedCornerShape(24.dp))
.background(AppColors.inputBackground)
.border(
width = 2.dp,
color = if (error == null) Color.Transparent else AppColors.error,
shape = RoundedCornerShape(24.dp)
)
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically){
BasicTextField(
value = text,
onValueChange = onValueChange,
modifier = Modifier
.weight(1f)
.onFocusChanged { focusState ->
isFocused = focusState.isFocused
},
textStyle = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.W500,
color = AppColors.text
),
keyboardOptions = KeyboardOptions(
keyboardType = if (password) KeyboardType.Password else KeyboardType.Text
),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
singleLine = true,
enabled = enabled,
cursorBrush = SolidColor(AppColors.text),
)
if (password) {
Image(
painter = painterResource(id = R.drawable.rider_pro_eye),
contentDescription = "Password",
modifier = Modifier
.size(18.dp)
.noRippleClickable {
showPassword = !showPassword
},
colorFilter = ColorFilter.tint(AppColors.text)
)
}
}
if (text.isEmpty()) {
hint?.let {
Text(it, modifier = Modifier.padding(start = 5.dp), color = AppColors.inputHint, fontWeight = FontWeight.W600)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
AnimatedVisibility(
visible = error != null,
enter = fadeIn(),
exit = fadeOut()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(id = R.mipmap.rider_pro_input_error),
contentDescription = "Error",
modifier = Modifier.size(8.dp)
)
Spacer(modifier = Modifier.size(4.dp))
AnimatedContent(targetState = error) { targetError ->
Text(targetError ?: "", color = AppColors.text, fontSize = 12.sp)
}
}
}
}
}
}

View File

@@ -0,0 +1,137 @@
package com.aiosman.ravenow.ui.composables.form
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
@Composable
fun FormTextInput(
modifier: Modifier = Modifier,
value: String,
label: String? = null,
error: String? = null,
hint: String? = null,
onValueChange: (String) -> Unit
) {
val AppColors = LocalAppTheme.current
Column(
modifier = modifier
) {
Row(
modifier = Modifier.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(AppColors.inputBackground)
.let {
if (error != null) {
it.border(1.dp, AppColors.error, RoundedCornerShape(24.dp))
} else {
it
}
}
.padding(17.dp),
verticalAlignment = Alignment.CenterVertically
) {
label?.let {
Text(
text = it,
modifier = Modifier
.widthIn(100.dp),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = AppColors.text
)
)
}
Box(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp)
) {
if (value.isEmpty()) {
Text(
text = hint ?: "",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = AppColors.inputHint
)
)
}
BasicTextField(
maxLines = 1,
value = value,
onValueChange = {
onValueChange(it)
},
singleLine = true,
textStyle = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = AppColors.text
),
cursorBrush = SolidColor(AppColors.text),
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
AnimatedVisibility(
visible = error != null,
enter = fadeIn(),
exit = fadeOut()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(id = R.mipmap.rider_pro_input_error),
contentDescription = "Error",
modifier = Modifier.size(8.dp)
)
Spacer(modifier = Modifier.size(4.dp))
AnimatedContent(targetState = error) { targetError ->
Text(targetError ?: "", color = AppColors.error, fontSize = 12.sp)
}
}
}
}
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2021 onebone <me@onebone.me>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
* OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.aiosman.ravenow.ui.composables.toolbar
@RequiresOptIn(
message = "This is an experimental API of compose-collapsing-toolbar. Any declarations with " +
"the annotation might be removed or changed in some way without any notice.",
level = RequiresOptIn.Level.WARNING
)
@Target(
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY,
AnnotationTarget.CLASS
)
@Retention(AnnotationRetention.BINARY)
annotation class ExperimentalToolbarApi

View File

@@ -0,0 +1,191 @@
/*
* Copyright (c) 2021 onebone <me@onebone.me>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
* OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.aiosman.ravenow.ui.composables.toolbar
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import kotlin.math.max
@Deprecated(
"Use AppBarContainer for naming consistency",
replaceWith = ReplaceWith(
"AppBarContainer(modifier, scrollStrategy, collapsingToolbarState, content)",
"me.onebone.toolbar"
)
)
@Composable
fun AppbarContainer(
modifier: Modifier = Modifier,
scrollStrategy: ScrollStrategy,
collapsingToolbarState: CollapsingToolbarState,
content: @Composable AppbarContainerScope.() -> Unit
) {
AppBarContainer(
modifier = modifier,
scrollStrategy = scrollStrategy,
collapsingToolbarState = collapsingToolbarState,
content = content
)
}
@Deprecated(
"AppBarContainer is replaced with CollapsingToolbarScaffold",
replaceWith = ReplaceWith(
"CollapsingToolbarScaffold",
"me.onebone.toolbar"
)
)
@Composable
fun AppBarContainer(
modifier: Modifier = Modifier,
scrollStrategy: ScrollStrategy,
/** The state of a connected collapsing toolbar */
collapsingToolbarState: CollapsingToolbarState,
content: @Composable AppbarContainerScope.() -> Unit
) {
val offsetY = remember { mutableStateOf(0) }
val flingBehavior = ScrollableDefaults.flingBehavior()
val (scope, measurePolicy) = remember(scrollStrategy, collapsingToolbarState) {
AppbarContainerScopeImpl(scrollStrategy.create(offsetY, collapsingToolbarState, flingBehavior)) to
AppbarMeasurePolicy(scrollStrategy, collapsingToolbarState, offsetY)
}
Layout(
content = { scope.content() },
measurePolicy = measurePolicy,
modifier = modifier
)
}
interface AppbarContainerScope {
fun Modifier.appBarBody(): Modifier
}
internal class AppbarContainerScopeImpl(
private val nestedScrollConnection: NestedScrollConnection
): AppbarContainerScope {
override fun Modifier.appBarBody(): Modifier {
return this
.then(AppBarBodyMarkerModifier)
.nestedScroll(nestedScrollConnection)
}
}
private object AppBarBodyMarkerModifier: ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any {
return AppBarBodyMarker
}
}
private object AppBarBodyMarker
private class AppbarMeasurePolicy(
private val scrollStrategy: ScrollStrategy,
private val toolbarState: CollapsingToolbarState,
private val offsetY: State<Int>
): MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
var width = 0
var height = 0
var toolbarPlaceable: Placeable? = null
val nonToolbars = measurables.filter {
val data = it.parentData
if(data != AppBarBodyMarker) {
if(toolbarPlaceable != null)
throw IllegalStateException("There cannot exist multiple toolbars under single parent")
val placeable = it.measure(constraints.copy(
minWidth = 0,
minHeight = 0
))
width = max(width, placeable.width)
height = max(height, placeable.height)
toolbarPlaceable = placeable
false
}else{
true
}
}
val placeables = nonToolbars.map { measurable ->
val childConstraints = if(scrollStrategy == ScrollStrategy.ExitUntilCollapsed) {
constraints.copy(
minWidth = 0,
minHeight = 0,
maxHeight = max(0, constraints.maxHeight - toolbarState.minHeight)
)
}else{
constraints.copy(
minWidth = 0,
minHeight = 0
)
}
val placeable = measurable.measure(childConstraints)
width = max(width, placeable.width)
height = max(height, placeable.height)
placeable
}
height += (toolbarPlaceable?.height ?: 0)
return layout(
width.coerceIn(constraints.minWidth, constraints.maxWidth),
height.coerceIn(constraints.minHeight, constraints.maxHeight)
) {
toolbarPlaceable?.place(x = 0, y = offsetY.value)
placeables.forEach { placeable ->
placeable.place(
x = 0,
y = offsetY.value + (toolbarPlaceable?.height ?: 0)
)
}
}
}
}

View File

@@ -0,0 +1,386 @@
/*
* Copyright (c) 2021 onebone <me@onebone.me>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
* OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.aiosman.ravenow.ui.composables.toolbar
import androidx.annotation.FloatRange
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.animateTo
import androidx.compose.animation.core.tween
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.ScrollScope
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
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.clipToBounds
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
@Stable
class CollapsingToolbarState(
initial: Int = Int.MAX_VALUE
): ScrollableState {
/**
* [height] indicates current height of the toolbar.
*/
var height: Int by mutableStateOf(initial)
private set
/**
* [minHeight] indicates the minimum height of the collapsing toolbar. The toolbar
* may collapse its height to [minHeight] but not smaller. This size is determined by
* the smallest child.
*/
var minHeight: Int
get() = minHeightState
internal set(value) {
minHeightState = value
if(height < value) {
height = value
}
}
/**
* [maxHeight] indicates the maximum height of the collapsing toolbar. The toolbar
* may expand its height to [maxHeight] but not larger. This size is determined by
* the largest child.
*/
var maxHeight: Int
get() = maxHeightState
internal set(value) {
maxHeightState = value
if(value < height) {
height = value
}
}
private var maxHeightState by mutableStateOf(Int.MAX_VALUE)
private var minHeightState by mutableStateOf(0)
val progress: Float
@FloatRange(from = 0.0, to = 1.0)
get() =
if(minHeight == maxHeight) {
0f
}else{
((height - minHeight).toFloat() / (maxHeight - minHeight)).coerceIn(0f, 1f)
}
private val scrollableState = ScrollableState { value ->
val consume = if(value < 0) {
max(minHeight.toFloat() - height, value)
}else{
min(maxHeight.toFloat() - height, value)
}
val current = consume + deferredConsumption
val currentInt = current.toInt()
if(current.absoluteValue > 0) {
height += currentInt
deferredConsumption = current - currentInt
}
consume
}
private var deferredConsumption: Float = 0f
/**
* @return consumed scroll value is returned
*/
@Deprecated(
message = "feedScroll() is deprecated, use dispatchRawDelta() instead.",
replaceWith = ReplaceWith("dispatchRawDelta(value)")
)
fun feedScroll(value: Float): Float = dispatchRawDelta(value)
@ExperimentalToolbarApi
suspend fun expand(duration: Int = 200) {
val anim = AnimationState(height.toFloat())
scroll {
var prev = anim.value
anim.animateTo(maxHeight.toFloat(), tween(duration)) {
scrollBy(value - prev)
prev = value
}
}
}
@ExperimentalToolbarApi
suspend fun collapse(duration: Int = 200) {
val anim = AnimationState(height.toFloat())
scroll {
var prev = anim.value
anim.animateTo(minHeight.toFloat(), tween(duration)) {
scrollBy(value - prev)
prev = value
}
}
}
/**
* @return Remaining velocity after fling
*/
suspend fun fling(flingBehavior: FlingBehavior, velocity: Float): Float {
var left = velocity
scroll {
with(flingBehavior) {
left = performFling(left)
}
}
return left
}
override val isScrollInProgress: Boolean
get() = scrollableState.isScrollInProgress
override fun dispatchRawDelta(delta: Float): Float = scrollableState.dispatchRawDelta(delta)
override suspend fun scroll(
scrollPriority: MutatePriority,
block: suspend ScrollScope.() -> Unit
) = scrollableState.scroll(scrollPriority, block)
}
@Composable
fun rememberCollapsingToolbarState(
initial: Int = Int.MAX_VALUE
): CollapsingToolbarState {
return remember {
CollapsingToolbarState(
initial = initial
)
}
}
@Composable
fun CollapsingToolbar(
modifier: Modifier = Modifier,
clipToBounds: Boolean = true,
collapsingToolbarState: CollapsingToolbarState,
content: @Composable CollapsingToolbarScope.() -> Unit
) {
val measurePolicy = remember(collapsingToolbarState) {
CollapsingToolbarMeasurePolicy(collapsingToolbarState)
}
Layout(
content = { CollapsingToolbarScopeInstance.content() },
measurePolicy = measurePolicy,
modifier = modifier.then(
if (clipToBounds) {
Modifier.clipToBounds()
} else {
Modifier
}
)
)
}
private class CollapsingToolbarMeasurePolicy(
private val collapsingToolbarState: CollapsingToolbarState
): MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
val placeables = measurables.map {
it.measure(
constraints.copy(
minWidth = 0,
minHeight = 0,
maxHeight = Constraints.Infinity
)
)
}
val placeStrategy = measurables.map { it.parentData }
val minHeight = placeables.minOfOrNull { it.height }
?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0
val maxHeight = placeables.maxOfOrNull { it.height }
?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0
val maxWidth = placeables.maxOfOrNull{ it.width }
?.coerceIn(constraints.minWidth, constraints.maxWidth) ?: 0
collapsingToolbarState.also {
it.minHeight = minHeight
it.maxHeight = maxHeight
}
val height = collapsingToolbarState.height
return layout(maxWidth, height) {
val progress = collapsingToolbarState.progress
placeables.forEachIndexed { i, placeable ->
val strategy = placeStrategy[i]
if(strategy is CollapsingToolbarData) {
strategy.progressListener?.onProgressUpdate(progress)
}
when(strategy) {
is CollapsingToolbarRoadData -> {
val collapsed = strategy.whenCollapsed
val expanded = strategy.whenExpanded
val collapsedOffset = collapsed.align(
size = IntSize(placeable.width, placeable.height),
space = IntSize(maxWidth, height),
layoutDirection = layoutDirection
)
val expandedOffset = expanded.align(
size = IntSize(placeable.width, placeable.height),
space = IntSize(maxWidth, height),
layoutDirection = layoutDirection
)
val offset = collapsedOffset + (expandedOffset - collapsedOffset) * progress
placeable.place(offset.x, offset.y)
}
is CollapsingToolbarParallaxData ->
placeable.placeRelative(
x = 0,
y = -((maxHeight - minHeight) * (1 - progress) * strategy.ratio).roundToInt()
)
else -> placeable.placeRelative(0, 0)
}
}
}
}
}
interface CollapsingToolbarScope {
fun Modifier.progress(listener: ProgressListener): Modifier
fun Modifier.road(whenCollapsed: Alignment, whenExpanded: Alignment): Modifier
fun Modifier.parallax(ratio: Float = 0.2f): Modifier
fun Modifier.pin(): Modifier
}
internal object CollapsingToolbarScopeInstance: CollapsingToolbarScope {
override fun Modifier.progress(listener: ProgressListener): Modifier {
return this.then(ProgressUpdateListenerModifier(listener))
}
override fun Modifier.road(whenCollapsed: Alignment, whenExpanded: Alignment): Modifier {
return this.then(RoadModifier(whenCollapsed, whenExpanded))
}
override fun Modifier.parallax(ratio: Float): Modifier {
return this.then(ParallaxModifier(ratio))
}
override fun Modifier.pin(): Modifier {
return this.then(PinModifier())
}
}
internal class RoadModifier(
private val whenCollapsed: Alignment,
private val whenExpanded: Alignment
): ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any {
return CollapsingToolbarRoadData(
this@RoadModifier.whenCollapsed, this@RoadModifier.whenExpanded,
(parentData as? CollapsingToolbarData)?.progressListener
)
}
}
internal class ParallaxModifier(
private val ratio: Float
): ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any {
return CollapsingToolbarParallaxData(ratio, (parentData as? CollapsingToolbarData)?.progressListener)
}
}
internal class PinModifier: ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any {
return CollapsingToolbarPinData((parentData as? CollapsingToolbarData)?.progressListener)
}
}
internal class ProgressUpdateListenerModifier(
private val listener: ProgressListener
): ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any {
return CollapsingToolbarProgressData(listener)
}
}
fun interface ProgressListener {
fun onProgressUpdate(value: Float)
}
internal sealed class CollapsingToolbarData(
var progressListener: ProgressListener?
)
internal class CollapsingToolbarProgressData(
progressListener: ProgressListener?
): CollapsingToolbarData(progressListener)
internal class CollapsingToolbarRoadData(
var whenCollapsed: Alignment,
var whenExpanded: Alignment,
progressListener: ProgressListener? = null
): CollapsingToolbarData(progressListener)
internal class CollapsingToolbarPinData(
progressListener: ProgressListener? = null
): CollapsingToolbarData(progressListener)
internal class CollapsingToolbarParallaxData(
var ratio: Float,
progressListener: ProgressListener? = null
): CollapsingToolbarData(progressListener)

View File

@@ -0,0 +1,234 @@
/*
* Copyright (c) 2021 onebone <me@onebone.me>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
* OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.aiosman.ravenow.ui.composables.toolbar
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import kotlin.math.max
@Stable
class CollapsingToolbarScaffoldState(
val toolbarState: CollapsingToolbarState,
initialOffsetY: Int = 0
) {
val offsetY: Int
get() = offsetYState.value
internal val offsetYState = mutableStateOf(initialOffsetY)
}
private class CollapsingToolbarScaffoldStateSaver: Saver<CollapsingToolbarScaffoldState, List<Any>> {
override fun restore(value: List<Any>): CollapsingToolbarScaffoldState =
CollapsingToolbarScaffoldState(
CollapsingToolbarState(value[0] as Int),
value[1] as Int
)
override fun SaverScope.save(value: CollapsingToolbarScaffoldState): List<Any> =
listOf(
value.toolbarState.height,
value.offsetY
)
}
@Composable
fun rememberCollapsingToolbarScaffoldState(
toolbarState: CollapsingToolbarState = rememberCollapsingToolbarState()
): CollapsingToolbarScaffoldState {
return rememberSaveable(toolbarState, saver = CollapsingToolbarScaffoldStateSaver()) {
CollapsingToolbarScaffoldState(toolbarState)
}
}
interface CollapsingToolbarScaffoldScope {
@ExperimentalToolbarApi
fun Modifier.align(alignment: Alignment): Modifier
}
@Composable
fun CollapsingToolbarScaffold(
modifier: Modifier,
state: CollapsingToolbarScaffoldState,
scrollStrategy: ScrollStrategy,
enabled: Boolean = true,
toolbarModifier: Modifier = Modifier,
toolbarClipToBounds: Boolean = true,
toolbarScrollable: Boolean = false,
toolbar: @Composable CollapsingToolbarScope.(ScrollState) -> Unit,
body: @Composable CollapsingToolbarScaffoldScope.() -> Unit
) {
val flingBehavior = ScrollableDefaults.flingBehavior()
val layoutDirection = LocalLayoutDirection.current
val nestedScrollConnection = remember(scrollStrategy, state) {
scrollStrategy.create(state.offsetYState, state.toolbarState, flingBehavior)
}
val toolbarState = state.toolbarState
val toolbarScrollState = rememberScrollState()
Layout(
content = {
CollapsingToolbar(
modifier = toolbarModifier,
clipToBounds = toolbarClipToBounds,
collapsingToolbarState = toolbarState,
) {
ToolbarScrollableBox(
enabled,
toolbarScrollable,
toolbarState,
toolbarScrollState
)
toolbar(toolbarScrollState)
}
CollapsingToolbarScaffoldScopeInstance.body()
},
modifier = modifier
.then(
if (enabled) {
Modifier.nestedScroll(nestedScrollConnection)
} else {
Modifier
}
)
) { measurables, constraints ->
check(measurables.size >= 2) {
"the number of children should be at least 2: toolbar, (at least one) body"
}
val toolbarConstraints = constraints.copy(
minWidth = 0,
minHeight = 0
)
val bodyConstraints = constraints.copy(
minWidth = 0,
minHeight = 0,
maxHeight = when (scrollStrategy) {
ScrollStrategy.ExitUntilCollapsed ->
(constraints.maxHeight - toolbarState.minHeight).coerceAtLeast(0)
ScrollStrategy.EnterAlways, ScrollStrategy.EnterAlwaysCollapsed ->
constraints.maxHeight
}
)
val toolbarPlaceable = measurables[0].measure(toolbarConstraints)
val bodyMeasurables = measurables.subList(1, measurables.size)
val childrenAlignments = bodyMeasurables.map {
(it.parentData as? ScaffoldParentData)?.alignment
}
val bodyPlaceables = bodyMeasurables.map {
it.measure(bodyConstraints)
}
val toolbarHeight = toolbarPlaceable.height
val width = max(
toolbarPlaceable.width,
bodyPlaceables.maxOfOrNull { it.width } ?: 0
).coerceIn(constraints.minWidth, constraints.maxWidth)
val height = max(
toolbarHeight,
bodyPlaceables.maxOfOrNull { it.height } ?: 0
).coerceIn(constraints.minHeight, constraints.maxHeight)
layout(width, height) {
bodyPlaceables.forEachIndexed { index, placeable ->
val alignment = childrenAlignments[index]
if (alignment == null) {
placeable.placeRelative(0, toolbarHeight + state.offsetY)
} else {
val offset = alignment.align(
size = IntSize(placeable.width, placeable.height),
space = IntSize(width, height),
layoutDirection = layoutDirection
)
placeable.place(offset)
}
}
toolbarPlaceable.placeRelative(0, state.offsetY)
}
}
}
@Composable
private fun ToolbarScrollableBox(
enabled: Boolean,
toolbarScrollable: Boolean,
toolbarState: CollapsingToolbarState,
toolbarScrollState: ScrollState
) {
val toolbarScrollableEnabled = enabled && toolbarScrollable
if (toolbarScrollableEnabled && toolbarState.height != Constraints.Infinity) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(with(LocalDensity.current) { toolbarState.height.toDp() })
.verticalScroll(state = toolbarScrollState)
)
}
}
internal object CollapsingToolbarScaffoldScopeInstance: CollapsingToolbarScaffoldScope {
@ExperimentalToolbarApi
override fun Modifier.align(alignment: Alignment): Modifier =
this.then(ScaffoldChildAlignmentModifier(alignment))
}
private class ScaffoldChildAlignmentModifier(
private val alignment: Alignment
) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any {
return (parentData as? ScaffoldParentData) ?: ScaffoldParentData(alignment)
}
}
private data class ScaffoldParentData(
var alignment: Alignment? = null
)

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2021 onebone <me@onebone.me>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
* OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.aiosman.ravenow.ui.composables.toolbar
import androidx.compose.runtime.Immutable
@Immutable
class FabPlacement(
val left: Int,
val width: Int,
val height: Int
)

View File

@@ -0,0 +1,6 @@
package com.aiosman.ravenow.ui.composables.toolbar
enum class FabPosition {
Center,
End
}

View File

@@ -0,0 +1,239 @@
/*
* Copyright (c) 2021 onebone <me@onebone.me>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
* OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.aiosman.ravenow.ui.composables.toolbar
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.runtime.MutableState
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity
enum class ScrollStrategy {
EnterAlways {
override fun create(
offsetY: MutableState<Int>,
toolbarState: CollapsingToolbarState,
flingBehavior: FlingBehavior
): NestedScrollConnection =
EnterAlwaysNestedScrollConnection(offsetY, toolbarState, flingBehavior)
},
EnterAlwaysCollapsed {
override fun create(
offsetY: MutableState<Int>,
toolbarState: CollapsingToolbarState,
flingBehavior: FlingBehavior
): NestedScrollConnection =
EnterAlwaysCollapsedNestedScrollConnection(offsetY, toolbarState, flingBehavior)
},
ExitUntilCollapsed {
override fun create(
offsetY: MutableState<Int>,
toolbarState: CollapsingToolbarState,
flingBehavior: FlingBehavior
): NestedScrollConnection =
ExitUntilCollapsedNestedScrollConnection(toolbarState, flingBehavior)
};
internal abstract fun create(
offsetY: MutableState<Int>,
toolbarState: CollapsingToolbarState,
flingBehavior: FlingBehavior
): NestedScrollConnection
}
private class ScrollDelegate(
private val offsetY: MutableState<Int>
) {
private var scrollToBeConsumed: Float = 0f
fun doScroll(delta: Float) {
val scroll = scrollToBeConsumed + delta
val scrollInt = scroll.toInt()
scrollToBeConsumed = scroll - scrollInt
offsetY.value += scrollInt
}
}
internal class EnterAlwaysNestedScrollConnection(
private val offsetY: MutableState<Int>,
private val toolbarState: CollapsingToolbarState,
private val flingBehavior: FlingBehavior
): NestedScrollConnection {
private val scrollDelegate = ScrollDelegate(offsetY)
//private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl())
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val dy = available.y
val toolbar = toolbarState.height.toFloat()
val offset = offsetY.value.toFloat()
// -toolbarHeight <= offsetY + dy <= 0
val consume = if(dy < 0) {
val toolbarConsumption = toolbarState.dispatchRawDelta(dy)
val remaining = dy - toolbarConsumption
val offsetConsumption = remaining.coerceAtLeast(-toolbar - offset)
scrollDelegate.doScroll(offsetConsumption)
toolbarConsumption + offsetConsumption
}else{
val offsetConsumption = dy.coerceAtMost(-offset)
scrollDelegate.doScroll(offsetConsumption)
val toolbarConsumption = toolbarState.dispatchRawDelta(dy - offsetConsumption)
offsetConsumption + toolbarConsumption
}
return Offset(0f, consume)
}
override suspend fun onPreFling(available: Velocity): Velocity {
val left = if(available.y > 0) {
toolbarState.fling(flingBehavior, available.y)
}else{
// If velocity < 0, the main content should have a remaining scroll space
// so the scroll resumes to the onPreScroll(..., Fling) phase. Hence we do
// not need to process it at onPostFling() manually.
available.y
}
return Velocity(x = 0f, y = available.y - left)
}
}
internal class EnterAlwaysCollapsedNestedScrollConnection(
private val offsetY: MutableState<Int>,
private val toolbarState: CollapsingToolbarState,
private val flingBehavior: FlingBehavior
): NestedScrollConnection {
private val scrollDelegate = ScrollDelegate(offsetY)
//private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl())
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val dy = available.y
val consumed = if(dy > 0) { // expanding: offset -> body -> toolbar
val offsetConsumption = dy.coerceAtMost(-offsetY.value.toFloat())
scrollDelegate.doScroll(offsetConsumption)
offsetConsumption
}else{ // collapsing: toolbar -> offset -> body
val toolbarConsumption = toolbarState.dispatchRawDelta(dy)
val offsetConsumption = (dy - toolbarConsumption).coerceAtLeast(-toolbarState.height.toFloat() - offsetY.value)
scrollDelegate.doScroll(offsetConsumption)
toolbarConsumption + offsetConsumption
}
return Offset(0f, consumed)
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
val dy = available.y
return if(dy > 0) {
Offset(0f, toolbarState.dispatchRawDelta(dy))
}else{
Offset(0f, 0f)
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val dy = available.y
val left = if(dy > 0) {
// onPostFling() has positive available scroll value only called if the main scroll
// has leftover scroll, i.e. the scroll of the main content has done. So we just process
// fling if the available value is positive.
toolbarState.fling(flingBehavior, dy)
}else{
dy
}
return Velocity(x = 0f, y = available.y - left)
}
}
internal class ExitUntilCollapsedNestedScrollConnection(
private val toolbarState: CollapsingToolbarState,
private val flingBehavior: FlingBehavior
): NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val dy = available.y
val consume = if(dy < 0) { // collapsing: toolbar -> body
toolbarState.dispatchRawDelta(dy)
}else{
0f
}
return Offset(0f, consume)
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
val dy = available.y
val consume = if(dy > 0) { // expanding: body -> toolbar
toolbarState.dispatchRawDelta(dy)
}else{
0f
}
return Offset(0f, consume)
}
override suspend fun onPreFling(available: Velocity): Velocity {
val left = if(available.y < 0) {
toolbarState.fling(flingBehavior, available.y)
}else{
available.y
}
return Velocity(x = 0f, y = available.y - left)
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val velocity = available.y
val left = if(velocity > 0) {
toolbarState.fling(flingBehavior, velocity)
}else{
velocity
}
return Velocity(x = 0f, y = available.y - left)
}
}

View File

@@ -0,0 +1,107 @@
package com.aiosman.ravenow.ui.composables.toolbar
import androidx.compose.foundation.ScrollState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@ExperimentalToolbarApi
@Composable
fun ToolbarWithFabScaffold(
modifier: Modifier,
state: CollapsingToolbarScaffoldState,
scrollStrategy: ScrollStrategy,
toolbarModifier: Modifier = Modifier,
toolbarClipToBounds: Boolean = true,
toolbar: @Composable CollapsingToolbarScope.(ScrollState) -> Unit,
toolbarScrollable: Boolean = false,
fab: @Composable () -> Unit,
fabPosition: FabPosition = FabPosition.End,
body: @Composable CollapsingToolbarScaffoldScope.() -> Unit
) {
SubcomposeLayout(
modifier = modifier
) { constraints ->
val toolbarScaffoldConstraints = constraints.copy(
minWidth = 0,
minHeight = 0,
maxHeight = constraints.maxHeight
)
val toolbarScaffoldPlaceables = subcompose(ToolbarWithFabScaffoldContent.ToolbarScaffold) {
CollapsingToolbarScaffold(
modifier = modifier,
state = state,
scrollStrategy = scrollStrategy,
toolbarModifier = toolbarModifier,
toolbarClipToBounds = toolbarClipToBounds,
toolbar = toolbar,
body = body,
toolbarScrollable = toolbarScrollable
)
}.map { it.measure(toolbarScaffoldConstraints) }
val fabConstraints = constraints.copy(
minWidth = 0,
minHeight = 0
)
val fabPlaceables = subcompose(
ToolbarWithFabScaffoldContent.Fab,
fab
).mapNotNull { measurable ->
measurable.measure(fabConstraints).takeIf { it.height != 0 && it.width != 0 }
}
val fabPlacement = if (fabPlaceables.isNotEmpty()) {
val fabWidth = fabPlaceables.maxOfOrNull { it.width } ?: 0
val fabHeight = fabPlaceables.maxOfOrNull { it.height } ?: 0
// FAB distance from the left of the layout, taking into account LTR / RTL
val fabLeftOffset = if (fabPosition == FabPosition.End) {
if (layoutDirection == LayoutDirection.Ltr) {
constraints.maxWidth - 16.dp.roundToPx() - fabWidth
} else {
16.dp.roundToPx()
}
} else {
(constraints.maxWidth - fabWidth) / 2
}
FabPlacement(
left = fabLeftOffset,
width = fabWidth,
height = fabHeight
)
} else {
null
}
val fabOffsetFromBottom = fabPlacement?.let {
it.height + 16.dp.roundToPx()
}
val width = constraints.maxWidth
val height = constraints.maxHeight
layout(width, height) {
toolbarScaffoldPlaceables.forEach {
it.place(0, 0)
}
fabPlacement?.let { placement ->
fabPlaceables.forEach {
it.place(placement.left, height - fabOffsetFromBottom!!)
}
}
}
}
}
private enum class ToolbarWithFabScaffoldContent {
ToolbarScaffold, Fab
}