Skip to content

Instantly share code, notes, and snippets.

@c5inco
Last active February 22, 2022 16:24
Show Gist options
  • Save c5inco/2210c3d00c49d100dc7348b06ad58ca1 to your computer and use it in GitHub Desktop.
Save c5inco/2210c3d00c49d100dc7348b06ad58ca1 to your computer and use it in GitHub Desktop.
Small utility for framing a Compose Wear app in a watch bezel, whether round, square, or rectangular.
import android.content.res.Configuration
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material.Button
import androidx.wear.compose.material.MaterialTheme
import androidx.wear.compose.material.Text
import kotlin.math.*
val gray100 = Color(0xff333333)
val gray200 = Color(0xff444444)
val gray400 = Color(0xff1e1e1e)
val buttonShape = RoundedCornerShape(
topStart = 2.dp,
topEnd = 4.dp,
bottomStart = 2.dp,
bottomEnd = 4.dp
)
@Composable
fun RoundWatchPreviewScaffold(
screenWidthDp: Dp = 240.dp,
screenHeightDp: Dp = 240.dp,
centerContent: Boolean = true,
showGlare: Boolean = true,
buttons: Int = 3,
watchBandColor: Color = Color(0xff555555),
backgroundColor: Color = Color(0xffd6f0ff),
content: @Composable ColumnScope.() -> Unit
) {
val bezelSize = if (screenWidthDp >= 360.dp) 24.dp else 16.dp
val screenRadius = screenWidthDp.div(2f)
val hardwareRadius = screenRadius + bezelSize
val bandWidth = screenWidthDp.times(0.6667f)
val bandHeight = screenHeightDp.times(0.333f)
val bandCircleYPos = sqrt(hardwareRadius.value.pow(2f) - (bandWidth.value / 2f).pow(2f))
val bandSizeModifier = Modifier.size(bandWidth, bandHeight)
Box(
modifier = Modifier
.fillMaxWidth()
.height(bandHeight.times(2f) + (bandCircleYPos * 2f).dp)
.background(backgroundColor),
contentAlignment = Alignment.Center
) {
Column(
Modifier
.fillMaxHeight()
.align(Alignment.Center)
) {
WatchBand(
modifier = bandSizeModifier.rotate(180f),
color = watchBandColor
)
Spacer(Modifier.weight(1f))
WatchBand(
modifier = bandSizeModifier,
color = watchBandColor
)
}
Box(
contentAlignment = Alignment.Center
) {
if (buttons > 1) {
val secondaryBtnXNudge = 4.dp
val secondaryBtnYOffset = hardwareRadius.times(0.4f)
val secondaryBtnAnglePos = atan(secondaryBtnYOffset.div(hardwareRadius))
val secondaryBtnXOffset = hardwareRadius.times(cos(secondaryBtnAnglePos)) + secondaryBtnXNudge
val secondaryBtnRotation = 24f
SecondaryButton(
Modifier
.align(Alignment.Center)
.offset(y = -secondaryBtnYOffset, x = secondaryBtnXOffset)
.rotate(-secondaryBtnRotation)
)
if (buttons > 2) {
SecondaryButton(
Modifier
.align(Alignment.Center)
.offset(y = secondaryBtnYOffset, x = secondaryBtnXOffset)
.rotate(secondaryBtnRotation)
)
}
}
// Main button
MainButton(
Modifier
.align(Alignment.CenterEnd)
.offset(x = 14.dp)
)
// Watch face and bezel
Box(
Modifier
.border(
width = bezelSize.times(0.25f),
brush = SolidColor(gray400),
shape = CircleShape
)
.border(
width = bezelSize.times(0.4375f),
brush = SolidColor(gray200),
shape = CircleShape
)
.border(
width = bezelSize,
brush = SolidColor(gray400),
shape = CircleShape
)
.padding(bezelSize)
) {
Box(
Modifier
.clip(CircleShape)
.size(width = screenWidthDp, height = screenHeightDp)
.then(if (showGlare) Modifier.screenGlare() else Modifier)
) {
WatchShapeConfigurationProvider(isRound = true) {
MaterialTheme {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
verticalArrangement = if (centerContent) Arrangement.Center else Arrangement.Top,
horizontalAlignment = if (centerContent) Alignment.CenterHorizontally else Alignment.Start
) {
content()
}
}
}
}
}
}
}
}
@Composable
fun RectangleWatchPreviewScaffold(
screenWidthDp: Dp = 360.dp,
screenHeightDp: Dp = 360.dp,
centerContent: Boolean = true,
showGlare: Boolean = true,
buttons: Int = 1,
watchBandColor: Color = Color(0xff555555),
backgroundColor: Color = Color(0xffd6f0ff),
content: @Composable ColumnScope.() -> Unit
) {
val bezelSize = if (screenWidthDp >= 360.dp) 24.dp else 16.dp
val bezelCorners = screenWidthDp.times(0.3f)
val bandWidth = screenWidthDp.times(0.6667f)
val bandHeight = screenHeightDp.times(0.25f)
val bandSizeModifier = Modifier.size(bandWidth, bandHeight)
Box(
modifier = Modifier
.fillMaxWidth()
.height(bandHeight.times(1.8f) + screenHeightDp + bezelSize.times(2f))
.background(backgroundColor),
contentAlignment = Alignment.Center
) {
Column(
Modifier
.fillMaxHeight()
.align(Alignment.Center)
) {
WatchBand(
modifier = bandSizeModifier.rotate(180f),
color = watchBandColor
)
Spacer(Modifier.weight(1f))
WatchBand(
modifier = bandSizeModifier,
color = watchBandColor
)
}
Box(
contentAlignment = Alignment.Center
) {
if (buttons > 1) {
val secondaryBtnYOffset = 48.dp
val secondaryBtnXOffset = 10.dp
SecondaryButton(
Modifier
.align(Alignment.CenterEnd)
.offset(y = -secondaryBtnYOffset, x = secondaryBtnXOffset)
)
if (buttons > 2) {
SecondaryButton(
Modifier
.align(Alignment.CenterEnd)
.offset(y = secondaryBtnYOffset, x = secondaryBtnXOffset)
)
}
}
// Main button
MainButton(
Modifier
.align(Alignment.CenterEnd)
.offset(x = 14.dp)
)
// Watch face and bezel
Box(
Modifier
.border(
width = bezelSize.times(0.25f),
brush = SolidColor(gray400),
shape = RoundedCornerShape(bezelCorners)
)
.border(
width = bezelSize.times(0.4375f),
brush = SolidColor(gray200),
shape = RoundedCornerShape(bezelCorners)
)
.border(
width = bezelSize,
brush = SolidColor(gray400),
shape = RoundedCornerShape(bezelCorners)
)
.clip(RoundedCornerShape(bezelCorners))
.padding(bezelSize)
) {
Box(
Modifier
.background(MaterialTheme.colors.background)
.size(width = screenWidthDp, height = screenHeightDp)
.then(if (showGlare) Modifier.screenGlare() else Modifier)
) {
WatchShapeConfigurationProvider(isRound = false) {
MaterialTheme {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
verticalArrangement = if (centerContent) Arrangement.Center else Arrangement.Top,
horizontalAlignment = if (centerContent) Alignment.CenterHorizontally else Alignment.Start
) {
content()
}
}
}
}
}
}
}
}
@Preview
@Composable
fun ScaffoldPreview240() {
Column {
RoundWatchPreviewScaffold {
SampleContent()
}
}
}
@Preview
@Composable
fun ScaffoldPreview228() {
RoundWatchPreviewScaffold(
screenWidthDp = 228.dp,
screenHeightDp = 228.dp,
buttons = 2
) {
SampleContent()
}
}
@Preview
@Composable
fun ScaffoldPreview192() {
RoundWatchPreviewScaffold(
screenWidthDp = 192.dp,
screenHeightDp = 192.dp,
buttons = 1
) {
SampleContent()
}
}
@Preview(widthDp = 600)
@Composable
fun ScaffoldPreview384() {
RoundWatchPreviewScaffold(
screenWidthDp = 384.dp,
screenHeightDp = 384.dp
) {
SampleContent()
}
}
@Preview(widthDp = 640)
@Composable
fun ScaffoldPreview454() {
RoundWatchPreviewScaffold(
screenWidthDp = 454.dp,
screenHeightDp = 454.dp,
watchBandColor = Color.White
) {
SampleContent()
}
}
@Preview(widthDp = 600)
@Composable
fun SquarePreview360() {
RectangleWatchPreviewScaffold(
screenWidthDp = 240.dp,
screenHeightDp = 240.dp,
) {
SampleContent()
}
}
@Preview(widthDp = 600)
@Composable
fun RectanglePreview404x476() {
RectangleWatchPreviewScaffold(
screenWidthDp = 404.dp,
screenHeightDp = 476.dp,
) {
SampleContent()
}
}
@Composable
private fun SampleContent() {
Text(
text = "Hello there!",
color = MaterialTheme.colors.primary
)
Spacer(Modifier.height(8.dp))
Button(
modifier = Modifier.fillMaxWidth(0.6f),
onClick = { }
) {
Text("Tap")
}
}
@Composable
private fun WatchShapeConfigurationProvider(
isRound: Boolean,
content: @Composable () -> Unit
) {
val newConfiguration = Configuration(LocalConfiguration.current)
newConfiguration.screenLayout = newConfiguration.screenLayout and
Configuration.SCREENLAYOUT_ROUND_MASK.inv() or
if (isRound) {
Configuration.SCREENLAYOUT_ROUND_YES
} else {
Configuration.SCREENLAYOUT_ROUND_NO
}
CompositionLocalProvider(
LocalConfiguration provides newConfiguration,
content = content
)
}
@Composable
private fun SecondaryButton(
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Column(
Modifier
.size(height = 12.dp, width = 4.dp)
.background(gray100)
) {}
Row(
Modifier
.size(height = 28.dp, width = 10.dp)
.background(
color = gray400,
shape = buttonShape
)
.clip(buttonShape),
horizontalArrangement = Arrangement.End
) {
Column(
Modifier
.width(2.dp)
.fillMaxHeight()
.background(gray200)
) {}
}
}
}
@Composable
private fun MainButton(
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Column(
Modifier
.size(height = 16.dp, width = 3.dp)
.background(gray100)
) {}
Row(
Modifier
.size(height = 32.dp, width = 12.dp)
.background(
color = gray400,
shape = buttonShape
)
.clip(buttonShape),
horizontalArrangement = Arrangement.End
) {
Column(
Modifier
.width(3.dp)
.fillMaxHeight()
.background(gray200)
) {}
}
}
}
@Composable
private fun WatchBand(
modifier: Modifier = Modifier.size(width = 160.dp, height = 80.dp),
color: Color = Color(0xff555555)
) {
Canvas(modifier = modifier) {
drawPath(
path = watchBandPath(size.width, size.height),
brush = SolidColor(color)
)
}
}
private fun watchBandPath(
width: Float,
height: Float
): Path {
val path = Path()
path.lineTo(width, 0f)
path.cubicTo(width, 0f, width * 0.875f, height * 0.4f, width * 0.875f, height)
path.lineTo(width * 0.125f, height)
path.cubicTo(width * 0.125f, height, width * 0.1252f, height * 0.4f, 0f, 0f)
path.close()
return path
}
private fun Modifier.screenGlare(): Modifier = this.then(
Modifier.drawWithContent {
drawContent()
val (height, width) = this.size
val path = Path()
val grayColor = Color(0xff1e1e1e)
path.lineTo(width * 0.1f, 0f)
path.lineTo(width * 0.7f, height)
path.lineTo(0f, height)
path.lineTo(0f, 0f)
path.close()
drawPath(
path = path,
brush = Brush.verticalGradient(
listOf(
Color.White.copy(alpha = 0.3f),
grayColor.copy(alpha = 0.2f),
grayColor.copy(alpha = 0f)
)
)
)
}
)
@yschimke
Copy link

This is amazing. The one thing I struggle with is retaining precise control over the preview content. I want to try with big and small dps, say 228 and 192, but setting those in the annotation compresses the content causing layout issues.

Are there some options for this?

  1. set a scale modifier on the content to get it back to the annotation sizes?
  2. have a predictable width around the content so you can add it on to the annotation?
  3. size the preview annotation generously, but support a watchface dp in RoundWatchPreviewScaffold?

@c5inco
Copy link
Author

c5inco commented Dec 22, 2021

@yschimke I've made some updates to make the drawing of the hardware more parametric. Let me know how it works for you! I added example Previews of 192, 228, 240, 384, 454.

have a predictable width around the content so you can add it on to the annotation?

I work around this one by doing fillMaxWidth() which for now by default if no widthDp parameter is set on Preview ends up defaulting to 360.dp. If your display is going to be > 360.dp, you will have to increase the width explicitly with Preview for now since we don't have the ability to fit the Preview to content size when exceeding the default device dimensions used under the hood, i.e. 360x640. You can see this in the example Previews above for 384 and 454.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment