Apple apps and devices have at all times been a key design ingredient and inspiration for our Clone Collection: Utility works And card app. After they introduced the brand new Apple Watch Extremely, the design of the depth gauge widget caught our consideration and we thought it could be nice to copy this widget on Android! As traditional for our Android clone challenges, we used the Jetpack Compose framework.
This text will present you the way we do it — create a wave impact, let water encompass the textual content, and mix colours. We really feel this will likely be useful for each learners and people who are used to Jetpack Compose.
Water degree
First, let us take a look at probably the most trivial drawback — find out how to calculate and animate the water degree.
enum class WaterLevelState {
StartReady,
Animating,
}
Subsequent, we outline the length of the animation and the preliminary state:
val waveDuration by rememberSaveable { mutableStateOf(waveDurationInMills) }
var waterLevelState by bear in mind { mutableStateOf(WaterLevelState.StartReady) }
Then we have to decide how the course of the water will change. It’s essential to report the progress on the display screen as textual content and to attract the water degree.
val waveProgress by waveProgressAsState(
timerState = waterLevelState,
timerDurationInMillis = waveDuration
)
Here is a better have a look at waveProgressAsState
. We use animatable
as a result of it provides us somewhat extra management and customization. For instance, we are able to specify completely different animationSpec for various states.
Now to calculate the coordinates of the water edge to be drawn on the display screen:
val waterLevel by bear in mind(waveProgress, containerSize.peak) {
derivedStateOf {
(waveProgress * containerSize.peak).toInt()
}
}
In any case this preliminary work, we are able to transfer on to precise wave technology.
Wave
The most typical approach to simulate a wave is to make use of a sine graph that strikes horizontally at a sure pace.
We would like it to look extra sensible and it must run by way of the weather on the display screen, so we want a extra advanced method. The principle thought of the implementation is to outline a set of factors representing the peak of the wave. Values are animated to create a wave impact.
First, we create an inventory with factors to retailer the values:
val factors = bear in mind(spacing, containerSize) {
derivedStateOf {
(-spacing..containerSize.width + spacing step spacing).map { x ->
PointF(x.toFloat(), waterLevel)
}
}
}
Then, within the case of water flowing usually when there are not any obstacles in its path, we simply must fill within the values of the water degree. We are going to take into account different instances later.
LevelState.PlainMoving -> {
factors.worth.map {
it.y = waterLevel
}
}
Take into account an animation that can change the peak of every level. Animating all of the factors will take a heavy toll on efficiency and battery. So, to save lots of sources, we’ll simply use a small variety of Float animation values:
@Composable
enjoyable createAnimationsAsState1(
pointsQuantity: Int,
): MutableList> {
val animations = bear in mind { mutableListOf>() }
val random = bear in mind { Random(System.currentTimeMillis()) }
val infiniteAnimation = rememberInfiniteTransition()
repeat(pointsQuantity / 2) {
val durationMillis = random.nextInt(2000, 6000)
animations += infiniteAnimation.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis),
repeatMode = RepeatMode.Reverse,
)
)
}
return animations
}
To stop the animation from repeating each 15 factors and the waves from being similar, we are able to set initialMultipliers:
@Composable
enjoyable createInitialMultipliersAsState(pointsQuantity: Int): MutableList {
val random = bear in mind { Random(System.currentTimeMillis()) }
return bear in mind {
mutableListOf().apply {
repeat(pointsQuantity) { this += random.nextFloat() }
}
}
}
Now so as to add waves – loop by way of all factors and calculate new heights.
factors.forEachIndexed { index, pointF ->
val newIndex = index % animations.measurement
var waveHeight = calculateWaveHeight(
animations[newIndex].worth,
initialMultipliers[index],
maxHeight
)
pointF.y = pointF.y - waveHeight
}
return factors
Extra initialMultipliers
ARRIVE currentSize
will scale back the probability of repeating values. And utilizing linear interpolation will easy the elevation change:
non-public enjoyable calculateWaveHeight(
currentSize: Float,
initialMultipliers: Float,
maxHeight: Float
): Float {
var waveHeightPercent = initialMultipliers + currentSize
if (waveHeightPercent > 1.0f) {
val diff = waveHeightPercent - 1.0f
waveHeightPercent = 1.0f - diff
}
return lerpF(maxHeight, 0f, waveHeightPercent)
}
Now for probably the most fascinating half — find out how to make water circulation round UI parts.
Interactive water motion
We begin by figuring out the three states that water has when the water degree drops. The PlainMoving
the identify speaks for itself, WaveIsComing
is the second when the water reaches the UX ingredient, the water will circulation round and it’s important to present that. FlowsAround
is the second that basically flows round a UI ingredient.
sealed class LevelState {
object PlainMoving : LevelState()
object FlowsAround : LevelState()
object WaveIsComing: LevelState()
}
We perceive that the water degree is larger than the merchandise if the water degree is lower than the merchandise location minus the buffer. This space is proven in pink on the picture under.
enjoyable isAboveElement(waterLevel: Int, bufferY: Float, place: Offset) = waterLevel < place.y - bufferY
When the water degree is at ingredient degree, it’s nonetheless too early to begin flowing round. This space is proven in grey within the subsequent determine.
enjoyable atElementLevel(
waterLevel: Int,
buffer: Float,
elementParams: ElementParams,
) = (waterLevel >= (elementParams.place.y - buffer)) &&
(waterLevel < (elementParams.place.y + elementParams.measurement.peak * 0.33))
enjoyable isWaterFalls(
waterLevel: Int,
elementParams: ElementParams,
) = waterLevel >= (elementParams.place.y + elementParams.measurement.peak * 0.33) &&
waterLevel <= (elementParams.place.y + elementParams.measurement.peak)
One other query we should take into account is — find out how to calculate the time of the water circulation? The animation of waterfalls and waves will increase when the water degree is within the blue space. Thus, we have to calculate the time that the water degree passes by way of 2/3 of the peak of the ingredient.
@Composable
enjoyable rememberDropWaterDuration(
elementSize: IntSize,
containerSize: IntSize,
length: Lengthy,
): Int {
return bear in mind(
elementSize,
containerSize
) { (((length * elementSize.peak * 0.66) / (containerSize.peak))).toInt() }
}
Let’s take a better have a look at the circulation across the ingredient. The form of the water circulation relies on a parabola — we selected a easy form for the sake of steering. We use the factors proven within the determine that the parabola passes by way of. We don’t lengthen the parabola under the present water degree (clear blurred pink line).
is LevelState.FlowsAround -> {
val point1 = PointF(
place.x,
place.y - buffer / 5
)
val point2 = point1.copy(x = place.x + elementSize.width)
val point3 = PointF(
place.x + elementSize.width / 2,
place.y - buffer
)
val p = Parabola(point1, point2, point3)
factors.worth.forEach {
val pr = p.calculate(it.x)
if (pr > waterLevel) {
it.y = waterLevel
} else {
it.y = pr
}
}
}
Let’s have a look at the waterfall animation: we’ll use the identical parabola, change its peak from the unique place and OvershootInterpolator
for a softer falling impact.
val parabolaHeightMultiplier = animateFloatAsState(
targetValue = if (levelState == LevelState.WaveIsComing) 0f else -1f,
animationSpec = tween(
durationMillis = dropWaterDuration,
easing = { OvershootInterpolator(6f).getInterpolation(it) }
)
)
On this case we use peak multiply animation in order that in the long run the peak of the parabola turns into 0.
val point1 by bear in mind(place, elementSize, waterLevel, parabolaHeightMultiplier) {
mutableStateOf(
PointF(
place.x,
waterLevel + (elementSize.peak / 3f + buffer / 5) * parabolaHeightMultiplier.worth
)
)
}
val point2 by bear in mind(place, elementSize, waterLevel, parabolaHeightMultiplier) {
mutableStateOf(
PointF(
place.x + elementSize.width,
waterLevel + (elementSize.peak / 3f + buffer / 5) * parabolaHeightMultiplier.worth
)
)
}
val point3 by bear in mind(place, elementSize, parabolaHeightMultiplier, waterLevel) {
mutableStateOf(
PointF(
place.x + elementSize.width / 2,
waterLevel + (elementSize.peak / 3f + buffer) * parabolaHeightMultiplier.worth
)
)
}
return produceState(
initialValue = Parabola(point1, point2, point3),
key1 = point1,
key2 = point2,
key3 = point3
) {
this.worth = Parabola(point1, point2, point3)
}
Additionally, we have to change the dimensions of the waves the place they overlap the UI ingredient, as a result of in the mean time of water motion, they improve, then lower to their regular measurement.
val point1 by bear in mind(place, elementSize, waterLevel, parabolaHeightMultiplier) {
mutableStateOf(
PointF(
place.x,
waterLevel + (elementSize.peak / 3f + buffer / 5) * parabolaHeightMultiplier.worth
)
)
}
val point2 by bear in mind(place, elementSize, waterLevel, parabolaHeightMultiplier) {
mutableStateOf(
PointF(
place.x + elementSize.width,
waterLevel + (elementSize.peak / 3f + buffer / 5) * parabolaHeightMultiplier.worth
)
)
}
val point3 by bear in mind(place, elementSize, parabolaHeightMultiplier, waterLevel) {
mutableStateOf(
PointF(
place.x + elementSize.width / 2,
waterLevel + (elementSize.peak / 3f + buffer) * parabolaHeightMultiplier.worth
)
)
}
return produceState(
initialValue = Parabola(point1, point2, point3),
key1 = point1,
key2 = point2,
key3 = point3
) {
this.worth = Parabola(point1, point2, point3)
}
The peak of the wave is elevated by the radius across the UI ingredient for extra realism.
val elementRangeX = (place.x - bufferX)..(place.x + elementSize.width + bufferX)
factors.forEach { index, pointF ->
if (levelState.worth is LevelState.WaveIsComing && pointF.x in elementRangeX) {
waveHeight *= waveMultiplier
}
}
Now it is time to mix every part we’ve and add shade mixing.
Mix all the weather
There are a number of methods you possibly can draw on canvas utilizing mix modes.
The primary technique that involves thoughts is to make use of a bitmap to attract the trail and draw the textual content utilizing Mix mode on a bitmapCanvas
. This method makes use of the outdated implementation of the canvas from the Android viewport, so we determined to make use of native as a substitute – apply a BlendMode to mix the colours. First, we draw the wave on the canvas.
Canvas(
modifier = Modifier
.background(Water)
.fillMaxSize()
) {
drawWaves(paths)
}
Within the implementation we use drawIntoCanvas
so we are able to use paint.pathEffectCornerPathEffect
to easy the waves.
enjoyable DrawScope.drawWaves(
paths: Paths,
) {
drawIntoCanvas {
it.drawPath(paths.pathList[1], paint.apply {
shade = Blue
})
it.drawPath(paths.pathList[0], paint.apply {
shade = Coloration.Black
alpha = 0.9f
})
}
}
To see how a lot house the textual content takes up, we put Textual content
ingredient into one Field
. From Textual content
do not assist BlendMode in format, we have to draw textual content on Canvas
use BlendMode, so we use drawWithContent
modifier to attract solely the textual content on the Canvas and never the textual content ingredient.
To make the mix mode work, a brand new layer must be created. To attain this we are able to use .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
*. The rendering of the content material will at all times be rendered to the offscreen buffer first, after which drawn to the vacation spot, no matter another parameters configured on the graphics layer.
* (That is an replace to our earlier implementation used .graphicsLayer(alpha = 0.99f)
cheat. @romainguy helped us have a clearer selection within the feedback).
Field(
modifier = modifier
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithContent {
drawTextWithBlendMode(
masks = paths.pathList[0],
textStyle = textStyle,
unitTextStyle = unitTextStyle,
textOffset = textOffset,
textual content = textual content,
unitTextOffset = unitTextProgress,
textMeasurer = textMeasurer,
)
}
) {
Textual content(
modifier = content material().modifier
.align(content material().align)
.onGloballyPositioned {
elementParams.place = it.positionInParent()
elementParams.measurement = it.measurement
},
textual content = "46FT",
model = content material().textStyle
)
}
First we draw the textual content, then we draw a wave, which is used as a masks. That is official doc relating to the completely different mix modes obtainable to builders
enjoyable DrawScope.drawTextWithBlendMode(
masks: Path,
textMeasurer: TextMeasurer,
textStyle: TextStyle,
textual content: String,
textOffset: Offset,
unitTextOffset: Offset,
unitTextStyle: TextStyle,
) {
drawText(
textMeasurer = textMeasurer,
topLeft = textOffset,
textual content = textual content,
model = textStyle,
)
drawText(
textMeasurer = textMeasurer,
topLeft = unitTextOffset,
textual content = "FT",
model = unitTextStyle,
)
drawPath(
path = masks,
shade = Water,
blendMode = BlendMode.SrcIn
)
}
Now you possibly can see the complete outcomes:
Conclusion
This turned out to be a slightly convoluted implementation, however that’s to be anticipated primarily based on the supply documentation. We’re completely happy to have the ability to achieve this a lot utilizing the native Compose engine. You may also tweak the parameters to get a extra engaging water impact, however we determined to cease on the proof of idea. As traditional, the repo incorporates the complete implementation. Should you like this tutorial you possibly can verify find out how to clone dribbling sound app in Jetpack Compose This or discover extra fascinating stuff in our profile.