Last active
February 22, 2022 16:24
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 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
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?