Skip to content

Instantly share code, notes, and snippets.

@Digipom
Created May 31, 2023 14:58
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Digipom/ffab09bca7c2a61b6fb215f6709fc69b to your computer and use it in GitHub Desktop.
Save Digipom/ffab09bca7c2a61b6fb215f6709fc69b to your computer and use it in GitHub Desktop.
Show bubble tooltip overlays using a similar approach as Android Compose's DropdownMenu.
package com.example.bubble.ui.components
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.material3.MaterialTheme
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.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import kotlin.math.max
import kotlin.math.min
@Composable
fun Bubble(
expanded: Boolean,
onDismissRequest: () -> Unit,
color: Color = MaterialTheme.colorScheme.primaryContainer,
verticalOffset: Dp = 0.dp,
content: @Composable () -> Unit
) {
// Similar approach as DropdownMenu.
val expandedStates = remember { MutableTransitionState(false) }
expandedStates.targetState = expanded
if (expandedStates.currentState || expandedStates.targetState) {
var transformOriginState by remember { mutableStateOf(TransformOrigin.Center) }
val density = LocalDensity.current
var arrowX by remember { mutableStateOf(0.dp) }
val positionProvider =
BubblePositionProvider(
verticalOffset = verticalOffset,
density = density
) { parentBounds, bubbleBounds, newArrowX ->
arrowX = with(density) { newArrowX.toDp() }
transformOriginState = calculateTransformOrigin(parentBounds, bubbleBounds)
}
Popup(onDismissRequest = onDismissRequest, popupPositionProvider = positionProvider) {
BubbleContent(
expandedStates = expandedStates,
transformOriginState = transformOriginState,
color = color,
arrowX = arrowX,
content = content
)
}
}
}
private class BubblePositionProvider(
val verticalOffset: Dp,
val density: Density,
val onPositionsCalculated: (parentBounds: IntRect, bubbleBounds: IntRect, arrowX: Int) -> Unit,
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
val verticalMargin = with(density) { verticalOffset.roundToPx() }
val x = (anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2)
.coerceIn(0..windowSize.width - popupContentSize.width)
val y = anchorBounds.top - popupContentSize.height - verticalMargin
val arrowX = anchorBounds.center.x - x
onPositionsCalculated(
anchorBounds,
IntRect(x, y, x + popupContentSize.width, y + popupContentSize.height),
arrowX
)
return IntOffset(x, y)
}
}
private fun calculateTransformOrigin(
parentBounds: IntRect,
menuBounds: IntRect
): TransformOrigin {
val pivotX = when {
menuBounds.left >= parentBounds.right -> 0f
menuBounds.right <= parentBounds.left -> 1f
menuBounds.width == 0 -> 0f
else -> {
val intersectionCenter =
(
max(parentBounds.left, menuBounds.left) +
min(parentBounds.right, menuBounds.right)
) / 2
(intersectionCenter - menuBounds.left).toFloat() / menuBounds.width
}
}
val pivotY = when {
menuBounds.top >= parentBounds.bottom -> 0f
menuBounds.bottom <= parentBounds.top -> 1f
menuBounds.height == 0 -> 0f
else -> {
val intersectionCenter =
(
max(parentBounds.top, menuBounds.top) +
min(parentBounds.bottom, menuBounds.bottom)
) / 2
(intersectionCenter - menuBounds.top).toFloat() / menuBounds.height
}
}
return TransformOrigin(pivotX, pivotY)
}
package com.example.bubble.ui.components
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.shadow
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@Composable
fun BubbleContent(
expandedStates: MutableTransitionState<Boolean>,
transformOriginState: TransformOrigin,
color: Color,
arrowX: Dp,
content: @Composable () -> Unit,
) {
val arrowSize = 16.dp
val transition = updateTransition(expandedStates, "Bubble")
// Similar animation as DropdownMenu
val scale by transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(
durationMillis = InTransitionDuration,
easing = LinearOutSlowInEasing
)
} else {
// Expanded to dismissed.
tween(
durationMillis = 1,
delayMillis = OutTransitionDuration - 1
)
}
}, label = "BubbleScale"
) {
if (it) {
// Menu is expanded.
1f
} else {
// Menu is dismissed.
0.8f
}
}
val alpha by transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(durationMillis = 30)
} else {
// Expanded to dismissed.
tween(durationMillis = OutTransitionDuration)
}
}, label = "BubbleAlpha"
) {
if (it) {
// Menu is expanded.
1f
} else {
// Menu is dismissed.
0f
}
}
val horizontalMargin = 8.dp
Surface(
modifier = Modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = alpha
transformOrigin = transformOriginState
}
.padding(horizontal = horizontalMargin),
color = color,
shape = BubbleShape(
cornerRadius = 12.dp,
arrowSize = arrowSize,
arrowX = arrowX - horizontalMargin,
),
tonalElevation = 2.dp,
shadowElevation = 2.dp,
content = {
Box(Modifier.padding(bottom = arrowSize)) {
content()
}
}
)
}
// Open/close animation.
private const val InTransitionDuration = 120
private const val OutTransitionDuration = 75
// Adapted from https://gist.github.com/SylpheM/1da000b0044ff5c60d8537e5f26d7f2d
private class BubbleShape(
private val cornerRadius: Dp,
private val arrowSize: Dp,
private val arrowX: Dp,
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val cornerRadiusPx = with(density) { cornerRadius.toPx() }
val cornerDiameterPx = 2 * cornerRadiusPx
val arrowSizePx = with(density) { arrowSize.toPx() }
val arrowXPx = with(density) { arrowX.toPx() }
fun Path.cornerArcTo(rect: Rect, startAngleDegrees: Float) {
arcTo(
rect = rect,
startAngleDegrees = startAngleDegrees,
sweepAngleDegrees = 90f,
forceMoveTo = false
)
}
val left = 0f
val right = size.width
val top = 0f
val bottom = size.height - arrowSizePx
return Outline.Generic(
Path().apply {
reset()
// Top left arc
cornerArcTo(
rect = Rect(
left = left,
top = top,
right = cornerDiameterPx,
bottom = cornerDiameterPx
),
startAngleDegrees = 180f,
)
// Top line
lineTo(x = right - cornerDiameterPx, y = top)
// Top right arc
cornerArcTo(
rect = Rect(
left = right - cornerDiameterPx,
top = top,
right = right,
bottom = cornerDiameterPx
),
startAngleDegrees = 270f,
)
// Right line
lineTo(x = right, y = bottom - cornerDiameterPx)
// Bottom right arc
cornerArcTo(
rect = Rect(
left = right - cornerDiameterPx,
top = bottom - cornerDiameterPx,
right = right,
bottom = bottom
),
startAngleDegrees = 0f,
)
// Bottom line and arrow
lineTo(x = arrowXPx + arrowSizePx * 0.5f, y = bottom)
lineTo(x = arrowXPx, y = size.height)
lineTo(x = arrowXPx - arrowSizePx * 0.5f, y = bottom)
lineTo(x = cornerDiameterPx, y = bottom)
// Bottom left arc
cornerArcTo(
rect = Rect(
left = left,
top = bottom - cornerDiameterPx,
right = cornerDiameterPx,
bottom = bottom
),
startAngleDegrees = 90.0f,
)
// Left line
lineTo(x = left, y = cornerRadiusPx)
}
)
}
}
@Preview
@Composable
private fun BubbleShapePreview() {
MaterialTheme {
Surface {
Box(
modifier = Modifier
.width(200.dp)
.height(80.dp)
) {
val shape = BubbleShape(
cornerRadius = 10.dp,
arrowSize = 16.dp,
arrowX = 100.dp,
)
Card(
modifier = Modifier
.width(150.dp)
.height(60.dp)
.align(Alignment.Center)
.shadow(
elevation = 8.dp,
shape = shape,
spotColor = Color.Cyan
),
shape = shape,
border = BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.primary
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 16.dp)
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = "Hello world"
)
}
}
}
}
}
}
Box {
IconButton(onClick = { showOverflowMenu = !showOverflowMenu }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.more)
)
}
Bubble(
verticalOffset = -(8.dp),
expanded = showLanguagePickerBubble,
onDismissRequest = { showLanguagePickerBubble = false },
) {
Text(
modifier = Modifier.padding(16.dp),
text = stringResource(R.string.download_language_pack_to_transcribe_audio_tooltip),
style = MaterialTheme.typography.titleMedium,
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment