From e616c835c04fd457299eb8b928eca5d373736c79 Mon Sep 17 00:00:00 2001 From: Mahdi Hosseinzadeh Date: Fri, 2 Feb 2024 13:17:15 +0330 Subject: [PATCH] Add Android implementation code and add links to its source code --- README.md | 267 +++++++++++++++++- .../mahozad/multiplatform/wavyslider/Base.kt | 2 + 2 files changed, 268 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3133470..b2e892a 100644 --- a/README.md +++ b/README.md @@ -86,11 +86,276 @@ fun MyComposable() { ``` ## Related projects + - Android squiggly progress: + +
+ Current implementation (as of 2024-02-02) + + ```kotlin + /* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + package com.android.systemui.media.controls.ui + + import android.animation.Animator + import android.animation.AnimatorListenerAdapter + import android.animation.ValueAnimator + import android.content.res.ColorStateList + import android.graphics.Canvas + import android.graphics.ColorFilter + import android.graphics.Paint + import android.graphics.Path + import android.graphics.PixelFormat + import android.graphics.drawable.Drawable + import android.os.SystemClock + import android.util.MathUtils.lerp + import android.util.MathUtils.lerpInv + import android.util.MathUtils.lerpInvSat + import androidx.annotation.VisibleForTesting + import com.android.app.animation.Interpolators + import com.android.internal.graphics.ColorUtils + import kotlin.math.abs + import kotlin.math.cos + + private const val TAG = "Squiggly" + + private const val TWO_PI = (Math.PI * 2f).toFloat() + @VisibleForTesting internal const val DISABLED_ALPHA = 77 + + class SquigglyProgress : Drawable() { + + private val wavePaint = Paint() + private val linePaint = Paint() + private val path = Path() + private var heightFraction = 0f + private var heightAnimator: ValueAnimator? = null + private var phaseOffset = 0f + private var lastFrameTime = -1L + + /* distance over which amplitude drops to zero, measured in wavelengths */ + private val transitionPeriods = 1.5f + /* wave endpoint as percentage of bar when play position is zero */ + private val minWaveEndpoint = 0.2f + /* wave endpoint as percentage of bar when play position matches wave endpoint */ + private val matchedWaveEndpoint = 0.6f + + // Horizontal length of the sine wave + var waveLength = 0f + // Height of each peak of the sine wave + var lineAmplitude = 0f + // Line speed in px per second + var phaseSpeed = 0f + // Progress stroke width, both for wave and solid line + var strokeWidth = 0f + set(value) { + if (field == value) { + return + } + field = value + wavePaint.strokeWidth = value + linePaint.strokeWidth = value + } + + // Enables a transition region where the amplitude + // of the wave is reduced linearly across it. + var transitionEnabled = true + set(value) { + field = value + invalidateSelf() + } + + init { + wavePaint.strokeCap = Paint.Cap.ROUND + linePaint.strokeCap = Paint.Cap.ROUND + linePaint.style = Paint.Style.STROKE + wavePaint.style = Paint.Style.STROKE + linePaint.alpha = DISABLED_ALPHA + } + + var animate: Boolean = false + set(value) { + if (field == value) { + return + } + field = value + if (field) { + lastFrameTime = SystemClock.uptimeMillis() + } + heightAnimator?.cancel() + heightAnimator = + ValueAnimator.ofFloat(heightFraction, if (animate) 1f else 0f).apply { + if (animate) { + startDelay = 60 + duration = 800 + interpolator = Interpolators.EMPHASIZED_DECELERATE + } else { + duration = 550 + interpolator = Interpolators.STANDARD_DECELERATE + } + addUpdateListener { + heightFraction = it.animatedValue as Float + invalidateSelf() + } + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + heightAnimator = null + } + } + ) + start() + } + } + + override fun draw(canvas: Canvas) { + if (animate) { + invalidateSelf() + val now = SystemClock.uptimeMillis() + phaseOffset += (now - lastFrameTime) / 1000f * phaseSpeed + phaseOffset %= waveLength + lastFrameTime = now + } + + val progress = level / 10_000f + val totalWidth = bounds.width().toFloat() + val totalProgressPx = totalWidth * progress + val waveProgressPx = + totalWidth * + (if (!transitionEnabled || progress > matchedWaveEndpoint) progress + else + lerp( + minWaveEndpoint, + matchedWaveEndpoint, + lerpInv(0f, matchedWaveEndpoint, progress) + )) + + // Build Wiggly Path + val waveStart = -phaseOffset - waveLength / 2f + val waveEnd = if (transitionEnabled) totalWidth else waveProgressPx + + // helper function, computes amplitude for wave segment + val computeAmplitude: (Float, Float) -> Float = { x, sign -> + if (transitionEnabled) { + val length = transitionPeriods * waveLength + val coeff = + lerpInvSat(waveProgressPx + length / 2f, waveProgressPx - length / 2f, x) + sign * heightFraction * lineAmplitude * coeff + } else { + sign * heightFraction * lineAmplitude + } + } + + // Reset path object to the start + path.rewind() + path.moveTo(waveStart, 0f) + + // Build the wave, incrementing by half the wavelength each time + var currentX = waveStart + var waveSign = 1f + var currentAmp = computeAmplitude(currentX, waveSign) + val dist = waveLength / 2f + while (currentX < waveEnd) { + waveSign = -waveSign + val nextX = currentX + dist + val midX = currentX + dist / 2 + val nextAmp = computeAmplitude(nextX, waveSign) + path.cubicTo(midX, currentAmp, midX, nextAmp, nextX, nextAmp) + currentAmp = nextAmp + currentX = nextX + } + + // translate to the start position of the progress bar for all draw commands + val clipTop = lineAmplitude + strokeWidth + canvas.save() + canvas.translate(bounds.left.toFloat(), bounds.centerY().toFloat()) + + // Draw path up to progress position + canvas.save() + canvas.clipRect(0f, -1f * clipTop, totalProgressPx, clipTop) + canvas.drawPath(path, wavePaint) + canvas.restore() + + if (transitionEnabled) { + // If there's a smooth transition, we draw the rest of the + // path in a different color (using different clip params) + canvas.save() + canvas.clipRect(totalProgressPx, -1f * clipTop, totalWidth, clipTop) + canvas.drawPath(path, linePaint) + canvas.restore() + } else { + // No transition, just draw a flat line to the end of the region. + // The discontinuity is hidden by the progress bar thumb shape. + canvas.drawLine(totalProgressPx, 0f, totalWidth, 0f, linePaint) + } + + // Draw round line cap at the beginning of the wave + val startAmp = cos(abs(waveStart) / waveLength * TWO_PI) + canvas.drawPoint(0f, startAmp * lineAmplitude * heightFraction, wavePaint) + + canvas.restore() + } + + override fun getOpacity(): Int { + return PixelFormat.TRANSLUCENT + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + wavePaint.colorFilter = colorFilter + linePaint.colorFilter = colorFilter + } + + override fun setAlpha(alpha: Int) { + updateColors(wavePaint.color, alpha) + } + + override fun getAlpha(): Int { + return wavePaint.alpha + } + + override fun setTint(tintColor: Int) { + updateColors(tintColor, alpha) + } + + override fun onLevelChange(level: Int): Boolean { + return animate + } + + override fun setTintList(tint: ColorStateList?) { + if (tint == null) { + return + } + updateColors(tint.defaultColor, alpha) + } + + private fun updateColors(tintColor: Int, alpha: Int) { + wavePaint.color = ColorUtils.setAlphaComponent(tintColor, alpha) + linePaint.color = + ColorUtils.setAlphaComponent(tintColor, (DISABLED_ALPHA * (alpha / 255f)).toInt()) + } + } + ``` +
+ + [Main branch](https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/packages/SystemUI/src/com/android/systemui/media/controls/ui/SquigglyProgress.kt) + + [Android 14](https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android14-release/packages/SystemUI/src/com/android/systemui/media/controls/ui/SquigglyProgress.kt) + + [Android 13](https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android13-release/packages/SystemUI/src/com/android/systemui/media/SquigglyProgress.kt) + + [Android Music app](https://android.googlesource.com/platform/packages/apps/Music/) + + [Everything you see in Android that's not an app](https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/packages/SystemUI/) - Wave slider (similar to ours): https://github.com/galaxygoldfish/waveslider - Squiggly seekbar (Flutter): https://github.com/hannesgith/squiggly_slider - Sliders with custom styles: https://github.com/krottv/compose-sliders - Squiggly text underlines: https://github.com/saket/ExtendedSpans - - Android 13 music app: https://android.googlesource.com/platform/packages/apps/Music/ [Kotlin version]: https://img.shields.io/badge/Kotlin-1.9.22-303030.svg?labelColor=303030&logo=data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAxOC45MyAxOC45MiIgd2lkdGg9IjE4IiBoZWlnaHQ9IjE4IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxyYWRpYWxHcmFkaWVudCBpZD0iZ3JhZGllbnQiIHI9IjIxLjY3OSIgY3g9IjIyLjQzMiIgY3k9IjMuNDkzIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIDEgLTQuMTMgLTIuNzE4KSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICAgPHN0b3Agc3RvcC1jb2xvcj0iI2U0NDg1NyIgb2Zmc2V0PSIuMDAzIi8+CiAgICA8c3RvcCBzdG9wLWNvbG9yPSIjYzcxMWUxIiBvZmZzZXQ9Ii40NjkiLz4KICAgIDxzdG9wIHN0b3AtY29sb3I9IiM3ZjUyZmYiIG9mZnNldD0iMSIvPgogIDwvcmFkaWFsR3JhZGllbnQ+CiAgPHBhdGggZmlsbD0idXJsKCNncmFkaWVudCkiIGQ9Ik0gMTguOTMsMTguOTIgSCAwIFYgMCBIIDE4LjkzIEwgOS4yNyw5LjMyIFoiLz4KPC9zdmc+Cg== [Compose Multiplatform version]: https://img.shields.io/badge/Compose_Multiplatform-1.5.12-303030.svg?labelColor=303030&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAj5JREFUOI2Vk0FIVFEUhv9znBllplBIF7loK1jtJKhFNG/EVtYicNkmKghCMpJGq0HoPcWQVi2KUMqdixaJi0KdXVBILQojs4wCaTGC4LyX+N47fwtFpnEKOnDh3p//fudeDr+QRK3KukGHCscAwCjXi4PphVo+qQZkhzaa61J6m8RhAfpisS01HQOwZin0F29kftYEdDxCsqnkX6HgIonR+YHM00pjzg26oXRBPrNw30ixgM1dgDMcnFFyyIAphpn7xQI2Tw6XW5LQO0L+isPQKxaa1rNDaJCkf02BHhMpzOfTzxUA1GyCxEcFxjcOIu50/b4kZQnkZQJ9mkwuOV5wqaUdYSIhTwBZFto4AOj2R+S7qEwZMNtU8lcoGAPximZHDegAsCjgw7XP/rJFnDHBhEB+AABIIueW35FEdsQ/67hl5jz/AklUrpxX7nfcMp27wYnKO/rHCAwhANDkffW4DPJhZxtV6lpt/N+qCRCND+3RDHs0AEhUHii6KIxXSZnq9PxJTUhetrQ+VrsH4TlAvlgUfd3zAgMau0aD1uLNhm8WBm0CjBDoiSN8ijReJHBaRAYtTB8pFvaXukaDVgMadwFC6bWIM47n54GWaHYgM5CwunaASwBe1yXQNptPewDgeH7eIs4IpXcXMDeYnl5vzhxTINCUv+B4/vkXtxpWQEwK8Phlf3o15wbdmvLfCFgfh5njc4Pp6e3mVWHqHN44AOidnTC9NVpJRE+BKP0zTNW1HWc8IMxIvfq3OP8GvjkzgYHHZZMAAAAASUVORK5CYII= diff --git a/library/src/commonMain/kotlin/ir/mahozad/multiplatform/wavyslider/Base.kt b/library/src/commonMain/kotlin/ir/mahozad/multiplatform/wavyslider/Base.kt index 9ebbb8c..c9008b5 100644 --- a/library/src/commonMain/kotlin/ir/mahozad/multiplatform/wavyslider/Base.kt +++ b/library/src/commonMain/kotlin/ir/mahozad/multiplatform/wavyslider/Base.kt @@ -18,6 +18,8 @@ import androidx.compose.ui.unit.dp import ir.mahozad.multiplatform.wavyslider.WaveDirection.TAIL import kotlin.math.* +// For the Android 13/14 implementation, see the main README file. + /** * The horizontal movement (shift) of the whole wave. */