mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-19 08:09:12 +01:00
Fix PictureInPicture fling behavior and simplify focused mode.
This commit is contained in:
@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.components.webrtc.v2
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
@@ -35,6 +36,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
@@ -460,19 +462,28 @@ private fun SwitchCameraButton(
|
||||
}
|
||||
|
||||
val iconInset by animateDpAsState(targetIconInset)
|
||||
val hasActiveClick = (selfPipMode == SelfPipMode.EXPANDED_SELF_PIP || selfPipMode == SelfPipMode.FOCUSED_SELF_PIP) && onClick != null
|
||||
|
||||
val clickModifier = if ((selfPipMode == SelfPipMode.EXPANDED_SELF_PIP || selfPipMode == SelfPipMode.FOCUSED_SELF_PIP) && onClick != null) {
|
||||
val clickModifier = if (hasActiveClick) {
|
||||
Modifier.clickable { onClick() }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val background by animateColorAsState(
|
||||
if (hasActiveClick) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)
|
||||
}
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(end = margin, bottom = margin)
|
||||
.size(size)
|
||||
.background(
|
||||
color = Color(0xFF383838),
|
||||
color = background,
|
||||
shape = CircleShape
|
||||
)
|
||||
.padding(iconInset)
|
||||
@@ -606,6 +617,42 @@ enum class SelfPipMode {
|
||||
FOCUSED_SELF_PIP
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun SwitchCameraButtonOnPreview() {
|
||||
Previews.Preview {
|
||||
Box(
|
||||
modifier = Modifier.background(
|
||||
brush = Brush.linearGradient(colors = listOf(Color.Green, Color.Blue))
|
||||
)
|
||||
) {
|
||||
SwitchCameraButton(
|
||||
selfPipMode = SelfPipMode.EXPANDED_SELF_PIP,
|
||||
onClick = {},
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@NightPreview
|
||||
@Composable
|
||||
private fun SwitchCameraButtonOffPreview() {
|
||||
Previews.Preview {
|
||||
Box(
|
||||
modifier = Modifier.background(
|
||||
brush = Brush.linearGradient(colors = listOf(Color.Green, Color.Blue))
|
||||
)
|
||||
) {
|
||||
SwitchCameraButton(
|
||||
selfPipMode = SelfPipMode.NORMAL_SELF_PIP,
|
||||
onClick = {},
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// region Remote Participant Previews
|
||||
|
||||
@NightPreview
|
||||
|
||||
@@ -47,6 +47,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
@@ -174,6 +175,7 @@ fun CallScreen(
|
||||
sheetDragHandle = null,
|
||||
sheetPeekHeight = peekHeight.dp,
|
||||
sheetContainerColor = SignalTheme.colors.colorSurface1,
|
||||
containerColor = Color.Black,
|
||||
sheetMaxWidth = 540.dp,
|
||||
sheetContent = {
|
||||
BottomSheets.Handle(modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||
|
||||
@@ -65,6 +65,7 @@ fun MoveableLocalVideoRenderer(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val size = rememberSelfPipSize(localRenderState)
|
||||
val isFocused = localRenderState == WebRtcLocalRenderState.FOCUSED
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
@@ -73,60 +74,47 @@ fun MoveableLocalVideoRenderer(
|
||||
.statusBarsPadding()
|
||||
.displayCutoutPadding()
|
||||
) {
|
||||
val targetSize = size.let {
|
||||
if (it == DpSize.Unspecified) {
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
val desiredWidth = maxWidth - 32.dp
|
||||
val desiredHeight = maxHeight - 32.dp
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
val focusedSize = remember(maxWidth, maxHeight, orientation) {
|
||||
val desiredWidth = maxWidth - 32.dp
|
||||
val desiredHeight = maxHeight - 32.dp
|
||||
|
||||
val aspectRatio = if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
16f / 9f
|
||||
} else {
|
||||
9f / 16f
|
||||
}
|
||||
|
||||
val widthFromHeight = desiredHeight * aspectRatio
|
||||
val heightFromWidth = desiredWidth / aspectRatio
|
||||
|
||||
val size: DpSize = if (widthFromHeight <= desiredWidth) {
|
||||
DpSize(widthFromHeight, desiredHeight)
|
||||
} else {
|
||||
DpSize(desiredWidth, heightFromWidth)
|
||||
}
|
||||
|
||||
size
|
||||
val aspectRatio = if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
16f / 9f
|
||||
} else {
|
||||
it.rotateForConfiguration()
|
||||
9f / 16f
|
||||
}
|
||||
|
||||
val widthFromHeight = desiredHeight * aspectRatio
|
||||
val heightFromWidth = desiredWidth / aspectRatio
|
||||
|
||||
if (widthFromHeight <= desiredWidth) {
|
||||
DpSize(widthFromHeight, desiredHeight)
|
||||
} else {
|
||||
DpSize(desiredWidth, heightFromWidth)
|
||||
}
|
||||
}
|
||||
|
||||
val targetSize = if (isFocused) focusedSize else size.rotateForConfiguration()
|
||||
|
||||
val state = remember { PictureInPictureState(initialContentSize = targetSize) }
|
||||
state.animateTo(targetSize)
|
||||
|
||||
val selfPipMode = when (localRenderState) {
|
||||
WebRtcLocalRenderState.EXPANDED -> {
|
||||
SelfPipMode.EXPANDED_SELF_PIP
|
||||
}
|
||||
|
||||
WebRtcLocalRenderState.FOCUSED -> {
|
||||
SelfPipMode.FOCUSED_SELF_PIP
|
||||
}
|
||||
|
||||
WebRtcLocalRenderState.SMALLER_RECTANGLE -> {
|
||||
SelfPipMode.MINI_SELF_PIP
|
||||
}
|
||||
|
||||
else -> {
|
||||
SelfPipMode.NORMAL_SELF_PIP
|
||||
}
|
||||
WebRtcLocalRenderState.EXPANDED -> SelfPipMode.EXPANDED_SELF_PIP
|
||||
WebRtcLocalRenderState.FOCUSED -> SelfPipMode.FOCUSED_SELF_PIP
|
||||
WebRtcLocalRenderState.SMALLER_RECTANGLE -> SelfPipMode.MINI_SELF_PIP
|
||||
else -> SelfPipMode.NORMAL_SELF_PIP
|
||||
}
|
||||
|
||||
val clip by animateClip(localRenderState)
|
||||
val shadow by animateShadow(localRenderState)
|
||||
|
||||
val showFocusButton = localRenderState == WebRtcLocalRenderState.EXPANDED || isFocused
|
||||
|
||||
PictureInPicture(
|
||||
centerContent = size == DpSize.Unspecified,
|
||||
state = state,
|
||||
isFocused = isFocused,
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
@@ -143,13 +131,11 @@ fun MoveableLocalVideoRenderer(
|
||||
shadow = shadow
|
||||
)
|
||||
.clip(RoundedCornerShape(clip))
|
||||
.clickable(onClick = {
|
||||
onClick()
|
||||
})
|
||||
.clickable(onClick = onClick)
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = localRenderState == WebRtcLocalRenderState.EXPANDED || localRenderState == WebRtcLocalRenderState.FOCUSED,
|
||||
visible = showFocusButton,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
@@ -162,15 +148,14 @@ fun MoveableLocalVideoRenderer(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(
|
||||
when (localRenderState) {
|
||||
WebRtcLocalRenderState.FOCUSED -> R.drawable.symbol_minimize_24
|
||||
else -> R.drawable.symbol_maximize_24
|
||||
}
|
||||
if (isFocused) R.drawable.symbol_minimize_24 else R.drawable.symbol_maximize_24
|
||||
),
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
contentDescription = stringResource(
|
||||
when (localRenderState) {
|
||||
WebRtcLocalRenderState.FOCUSED -> R.string.MoveableLocalVideoRenderer__shrink_local_video
|
||||
else -> R.string.MoveableLocalVideoRenderer__expand_local_video
|
||||
if (isFocused) {
|
||||
R.string.MoveableLocalVideoRenderer__shrink_local_video
|
||||
} else {
|
||||
R.string.MoveableLocalVideoRenderer__expand_local_video
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -5,18 +5,17 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc.v2
|
||||
|
||||
import androidx.compose.animation.core.AnimationVector
|
||||
import androidx.compose.animation.core.AnimationVector2D
|
||||
import androidx.compose.animation.core.TwoWayConverter
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateIntOffsetAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.draggable2D
|
||||
import androidx.compose.foundation.gestures.rememberDraggable2DState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
@@ -24,10 +23,9 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.annotation.RememberInComposition
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -36,6 +34,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -48,18 +47,44 @@ import kotlin.math.sqrt
|
||||
|
||||
private const val DECELERATION_RATE = 0.99f
|
||||
|
||||
/**
|
||||
* Spring animation spec that mimics the View system's ViscousFluidInterpolator.
|
||||
* Uses no bounce with medium-low stiffness for natural physics-based fling deceleration.
|
||||
*/
|
||||
private val FlingAnimationSpec = spring<IntOffset>(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
|
||||
/**
|
||||
* Spring animation spec for position changes (corner to corner, or to/from center).
|
||||
* Uses a medium stiffness for responsive but smooth transitions.
|
||||
*/
|
||||
private val PositionAnimationSpec = spring<IntOffset>(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
|
||||
/**
|
||||
* Spring animation spec for size changes.
|
||||
*/
|
||||
private val SizeAnimationSpec = spring<Dp>(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
|
||||
/**
|
||||
* Displays moveable content in a bounding box and allows the user to drag it to
|
||||
* the four corners. Automatically adjusts itself as the bounding box and content
|
||||
* size changes.
|
||||
* size changes. When [isFocused] is true, the content centers and dragging is disabled.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun PictureInPicture(
|
||||
centerContent: Boolean,
|
||||
state: PictureInPictureState,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
isFocused: Boolean = false,
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
@@ -67,8 +92,6 @@ fun PictureInPicture(
|
||||
val density = LocalDensity.current
|
||||
val maxHeight = constraints.maxHeight
|
||||
val maxWidth = constraints.maxWidth
|
||||
val contentWidth = with(density) { state.contentSize.width.toPx().roundToInt() }
|
||||
val contentHeight = with(density) { state.contentSize.height.toPx().roundToInt() }
|
||||
val targetContentWidth = with(density) { state.targetSize.width.toPx().roundToInt() }
|
||||
val targetContentHeight = with(density) { state.targetSize.height.toPx().roundToInt() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -77,17 +100,6 @@ fun PictureInPicture(
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var isAnimating by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var offsetX by remember {
|
||||
mutableIntStateOf(maxWidth - contentWidth)
|
||||
}
|
||||
var offsetY by remember {
|
||||
mutableIntStateOf(maxHeight - contentHeight)
|
||||
}
|
||||
|
||||
val topLeft = remember {
|
||||
IntOffset(0, 0)
|
||||
}
|
||||
@@ -104,70 +116,76 @@ fun PictureInPicture(
|
||||
IntOffset(maxWidth - targetContentWidth, maxHeight - targetContentHeight)
|
||||
}
|
||||
|
||||
DisposableEffect(maxWidth, maxHeight, isAnimating, isDragging, targetContentWidth, targetContentHeight, centerContent) {
|
||||
if (!isAnimating && !isDragging) {
|
||||
if (centerContent) {
|
||||
offsetX = (maxWidth / 2f).roundToInt() - (targetContentWidth / 2f).roundToInt()
|
||||
offsetY = (maxHeight / 2f).roundToInt() - (targetContentHeight / 2f).roundToInt()
|
||||
} else {
|
||||
val offset = getDesiredCornerOffset(state.corner, topLeft, topRight, bottomLeft, bottomRight)
|
||||
|
||||
offsetX = offset.x
|
||||
offsetY = offset.y
|
||||
}
|
||||
}
|
||||
|
||||
onDispose { }
|
||||
val centerOffset = remember(maxWidth, maxHeight, targetContentWidth, targetContentHeight) {
|
||||
IntOffset(
|
||||
(maxWidth - targetContentWidth) / 2,
|
||||
(maxHeight - targetContentHeight) / 2
|
||||
)
|
||||
}
|
||||
|
||||
val animatedOffset by animateIntOffsetAsState(
|
||||
targetValue = IntOffset(offsetX, offsetY),
|
||||
animationSpec = tween()
|
||||
)
|
||||
val initialOffset = remember(maxWidth, maxHeight, targetContentWidth, targetContentHeight) {
|
||||
getDesiredCornerOffset(state.corner, topLeft, topRight, bottomLeft, bottomRight)
|
||||
}
|
||||
|
||||
val offsetAnimatable = remember {
|
||||
Animatable(initialOffset, IntOffset.VectorConverter)
|
||||
}
|
||||
|
||||
// Animate position when focused state changes or when constraints/corner changes
|
||||
LaunchedEffect(maxWidth, maxHeight, targetContentWidth, targetContentHeight, state.corner, isFocused) {
|
||||
if (!isDragging) {
|
||||
val targetOffset = if (isFocused) {
|
||||
centerOffset
|
||||
} else {
|
||||
getDesiredCornerOffset(state.corner, topLeft, topRight, bottomLeft, bottomRight)
|
||||
}
|
||||
|
||||
// Animate to new position (don't snap)
|
||||
offsetAnimatable.animateTo(
|
||||
targetValue = targetOffset,
|
||||
animationSpec = PositionAnimationSpec
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(state.contentSize)
|
||||
.offset {
|
||||
if (isDragging) {
|
||||
IntOffset(offsetX, offsetY)
|
||||
} else {
|
||||
animatedOffset
|
||||
}
|
||||
offsetAnimatable.value
|
||||
}
|
||||
.draggable2D(
|
||||
enabled = !isAnimating && !centerContent,
|
||||
enabled = !offsetAnimatable.isRunning && !isFocused,
|
||||
state = rememberDraggable2DState { offset ->
|
||||
offsetX += offset.x.roundToInt()
|
||||
offsetY += offset.y.roundToInt()
|
||||
coroutineScope.launch {
|
||||
offsetAnimatable.snapTo(
|
||||
IntOffset(
|
||||
offsetAnimatable.value.x + offset.x.roundToInt(),
|
||||
offsetAnimatable.value.y + offset.y.roundToInt()
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
onDragStarted = {
|
||||
isDragging = true
|
||||
},
|
||||
onDragStopped = { velocity ->
|
||||
isDragging = false
|
||||
isAnimating = true
|
||||
|
||||
val x = offsetX + project(velocity.x)
|
||||
val y = offsetY + project(velocity.y)
|
||||
val currentOffset = offsetAnimatable.value
|
||||
val x = currentOffset.x + project(velocity.x)
|
||||
val y = currentOffset.y + project(velocity.y)
|
||||
|
||||
val projectedCoordinate = IntOffset(x.roundToInt(), y.roundToInt())
|
||||
val (corner, offset) = getClosestCorner(projectedCoordinate, topLeft, topRight, bottomLeft, bottomRight)
|
||||
val (corner, targetOffset) = getClosestCorner(projectedCoordinate, topLeft, topRight, bottomLeft, bottomRight)
|
||||
state.corner = corner
|
||||
|
||||
coroutineScope.launch {
|
||||
animate(
|
||||
typeConverter = IntOffsetConverter,
|
||||
initialValue = IntOffset(offsetX, offsetY),
|
||||
targetValue = offset,
|
||||
offsetAnimatable.animateTo(
|
||||
targetValue = targetOffset,
|
||||
initialVelocity = IntOffset(velocity.x.roundToInt(), velocity.y.roundToInt()),
|
||||
animationSpec = tween()
|
||||
) { value, _ ->
|
||||
offsetX = value.x
|
||||
offsetY = value.y
|
||||
}
|
||||
|
||||
isAnimating = false
|
||||
animationSpec = FlingAnimationSpec
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -177,15 +195,6 @@ fun PictureInPicture(
|
||||
}
|
||||
}
|
||||
|
||||
private object IntOffsetConverter : TwoWayConverter<IntOffset, AnimationVector2D> {
|
||||
override val convertFromVector: (AnimationVector2D) -> IntOffset = { animationVector ->
|
||||
IntOffset(animationVector.v1.roundToInt(), animationVector.v2.roundToInt())
|
||||
}
|
||||
override val convertToVector: (IntOffset) -> AnimationVector2D = { intOffset ->
|
||||
AnimationVector(intOffset.x.toFloat(), intOffset.y.toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
private fun project(velocity: Float): Float {
|
||||
return (velocity / 1000f) * DECELERATION_RATE / (1f - DECELERATION_RATE)
|
||||
}
|
||||
@@ -231,8 +240,8 @@ class PictureInPictureState @RememberInComposition constructor(initialContentSiz
|
||||
fun animateTo(newTargetSize: DpSize) {
|
||||
targetSize = newTargetSize
|
||||
|
||||
val targetWidth by animateDpAsState(label = "animate-pip-width", targetValue = newTargetSize.width, animationSpec = tween())
|
||||
val targetHeight by animateDpAsState(label = "animate-pip-height", targetValue = newTargetSize.height, animationSpec = tween())
|
||||
val targetWidth by animateDpAsState(label = "animate-pip-width", targetValue = newTargetSize.width, animationSpec = SizeAnimationSpec)
|
||||
val targetHeight by animateDpAsState(label = "animate-pip-height", targetValue = newTargetSize.height, animationSpec = SizeAnimationSpec)
|
||||
|
||||
contentSize = DpSize(targetWidth, targetHeight)
|
||||
}
|
||||
@@ -247,7 +256,6 @@ private fun distance(a: IntOffset, b: IntOffset): Float {
|
||||
fun PictureInPicturePreview() {
|
||||
Previews.Preview {
|
||||
PictureInPicture(
|
||||
centerContent = false,
|
||||
state = remember { PictureInPictureState(initialContentSize = DpSize(90.dp, 160.dp)) },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
||||
Reference in New Issue
Block a user