Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save belinwu/baa74c45c5bb21523e09f697e0741b4d to your computer and use it in GitHub Desktop.

Select an option

Save belinwu/baa74c45c5bb21523e09f697e0741b4d to your computer and use it in GitHub Desktop.
An interactive 3D rotating sphere where labels and colorful dots are positioned on the sphere's surface, drag and see the Jadu :)
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.compose.ui.graphics.graphicsLayer
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
import kotlin.math.*
import kotlin.random.Random
private data class Vec3(val x: Float, val y: Float, val z: Float)
private fun sphericalToCartesian(radius: Float, theta: Float, phi: Float): Vec3 {
val st = sin(theta)
val x = radius * st * cos(phi)
val y = radius * cos(theta)
val z = radius * st * sin(phi)
return Vec3(x, y, z)
}
private fun rotateY(p: Vec3, angle: Float): Vec3 {
val c = cos(angle)
val s = sin(angle)
return Vec3(
p.x * c + p.z * s,
p.y,
-p.x * s + p.z * c
)
}
private fun rotateX(p: Vec3, angle: Float): Vec3 {
val c = cos(angle)
val s = sin(angle)
return Vec3(
p.x,
p.y * c - p.z * s,
p.y * s + p.z * c
)
}
private fun project(p: Vec3, fov: Float, cx: Float, cy: Float): Pair<Float, Float> {
val totalDistance = p.z + fov
val scale = fov / totalDistance
val x2 = p.x * scale + cx
val y2 = p.y * scale + cy
return x2 to y2
}
data class SphereLabel(val text: String, val icon: DrawableResource)
@Composable
fun StandaloneSphereTextDemo(
modifier: Modifier = Modifier.fillMaxSize(),
radius: Float = 400f,
labels: List<SphereLabel> = emptyList()
) {
var cameraYaw by remember { mutableStateOf(0f) }
var cameraPitch by remember { mutableStateOf(0f) }
val autoRotate = remember { mutableStateOf(0f) }
LaunchedEffect(Unit) {
while (true) {
autoRotate.value = (autoRotate.value + 0.0025f) % (2 * PI.toFloat())
delay(16)
}
}
val labelsWithPositions = remember(labels) {
labels.mapIndexed { index, label ->
val totalLabels = labels.size
val phi = acos(1 - 2 * (index + 0.5f) / totalLabels)
val theta = PI.toFloat() * (1 + sqrt(5f)) * (index + 1f)
Triple(label, theta, phi)
}
}
val randomDots = remember {
List(80) {
val theta = Random.nextFloat() * 2 * PI.toFloat()
val phi = Random.nextFloat() * PI.toFloat()
val color = listOf(
Color(0xFFFF6B9D),
Color(0xFF4A90E2),
Color(0xFF50E3C2),
Color(0xFFFF8A65),
Color(0xFFBA68C8),
Color(0xFF4DD0E1),
Color(0xFFFFD54F),
Color(0xFF7E57C2)
).random()
val size = 3f + Random.nextFloat() * (8f - 3f)
Triple(theta, phi, Pair(color, size))
}
}
val textStyle = TextStyle(
fontSize = 11.sp,
fontWeight = FontWeight.Normal
)
val textMeasurer = rememberTextMeasurer()
Box(
modifier = modifier
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
val (dx, dy) = dragAmount
cameraYaw += dx * 0.008f
cameraPitch += dy * 0.008f
}
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val center = Offset(size.width / 2f, size.height / 2f)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
Color(0xFFE3F2FD).copy(alpha = 0.3f),
Color(0xFFBBDEFB).copy(alpha = 0.2f),
Color(0xFF90CAF9).copy(alpha = 0.1f),
Color.Transparent
),
center = center,
radius = radius + 60f
),
radius = radius + 60f,
center = center
)
}
Box(modifier = Modifier.fillMaxSize()) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val widthPx = with(LocalDensity.current) { maxWidth.toPx() }
val heightPx = with(LocalDensity.current) { maxHeight.toPx() }
val cx = widthPx / 2f
val cy = heightPx / 2f
val fov = 800f
randomDots.forEach { (theta, phi, colorData) ->
val (color, size) = colorData
val p = sphericalToCartesian(radius, theta, phi)
val rotated = rotateX(rotateY(p, cameraYaw + autoRotate.value), cameraPitch)
if (rotated.z + fov <= 0.001f) return@forEach
val (x2, y2) = project(rotated, fov, cx, cy)
val scale = fov / (rotated.z + fov)
val depth = rotated.z
val dotAlpha = ((scale - 0.2f) / (1f - 0.2f)).coerceIn(0.2f, 1f)
val dotSize = (0.2f + (size * scale - 2f) / (6f - 2f) * (0.6f - 0.2f)).coerceIn(0.2f, 0.6f)
Canvas(modifier = Modifier
.offset { IntOffset(x2.roundToInt(), y2.roundToInt()) }
.size(dotSize.dp)
.zIndex(-depth)
) {
drawCircle(
color = color.copy(alpha = dotAlpha * 0.4f),
radius = this.size.width / 2f + 4f
)
drawCircle(
color = color.copy(alpha = dotAlpha),
radius = this.size.width / 2f
)
}
}
val projected = labelsWithPositions.map { (label, theta, phi) ->
val p = sphericalToCartesian(radius, theta, phi)
val rotated = rotateX(rotateY(p, cameraYaw + autoRotate.value), cameraPitch)
val depth = rotated.z
val (x2, y2) = project(rotated, fov, cx, cy)
val scale = fov / (rotated.z + fov)
Triple(label, LabelRenderInfo(rotated, x2, y2, depth, scale), label.text)
}.sortedBy { it.second.depth }
for (triple in projected) {
val label = triple.first
val info = triple.second
val text = triple.third
if (info.rotated.z + fov <= 0.001f) continue
val measured = textMeasurer.measure(
text = text,
style = textStyle
)
val textW = measured.size.width.toFloat()
val textH = measured.size.height.toFloat()
val baseScale = (0.6f + 0.8f * info.scale).coerceIn(0.4f, 1.8f)
val alpha = ((info.scale - 0.2f) / (1f - 0.2f)).coerceIn(0.1f, 1f)
val posX = info.x - textW / 2f
val posY = info.y - textH / 2f
val zInd = -info.depth
Box(
modifier = Modifier
.offset { IntOffset(posX.roundToInt(), posY.roundToInt()) }
.graphicsLayer {
transformOrigin = TransformOrigin(0.5f, 0.5f)
scaleX = baseScale
scaleY = baseScale
this.alpha = alpha
}
.zIndex(zInd)
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.border(
width = 0.5.dp,
color = Color(0xFFE0E0E0),
shape = RoundedCornerShape(12.dp)
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.background(color = Color(0xFF2196F3))
.padding(horizontal = 16.dp, vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(label.icon),
contentDescription = text,
modifier = Modifier.size(28.dp),
colorFilter = ColorFilter.tint(Color.White)
)
}
Box(
modifier = Modifier
.background(color = Color(0xFFE3F2FD))
.padding(horizontal = 20.dp, vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
style = textStyle.copy(
color = Color(0xFF1A1A1A),
fontWeight = FontWeight.Medium,
letterSpacing = 0.2.sp
)
)
}
}
}
}
Text(
text = "Drag to rotate",
style = TextStyle(
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFF64B5F6).copy(alpha = 0.5f),
letterSpacing = 0.8.sp
),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp)
)
}
}
}
}
private data class LabelRenderInfo(
val rotated: Vec3,
val x: Float,
val y: Float,
val depth: Float,
val scale: Float
)
// Usage : use Res for Compose Multiplatform
@Composable
fun MyCustomSphere() {
val customLabels = listOf(
SphereLabel("Work", Res.drawable.briefcase),
SphereLabel("Family", Res.drawable.profile_2user),
SphereLabel("Fitness", Res.drawable.calendar),
SphereLabel("Learning", Res.drawable.book),
SphereLabel("Hobbies", Res.drawable.home)
)
SphereTextDemo(
modifier = Modifier.fillMaxSize(),
radius = 350f, // Adjust sphere size
labels = customLabels
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment