From a8bd5696042d907cb9496cb44ca8f71b95c92691 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Martinez Date: Thu, 30 Jul 2026 10:17:26 -0700 Subject: [PATCH 1/6] Adding the LavaBeatsHapticHelper This is a utility to construct the LavaBeats haptic effect from pulse parameters that represent the QRS and T wave biomarker in an EKG --- .../lavabeats/LavaBeatsHapticHelper.kt | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsHapticHelper.kt diff --git a/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsHapticHelper.kt b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsHapticHelper.kt new file mode 100644 index 00000000..25a67802 --- /dev/null +++ b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsHapticHelper.kt @@ -0,0 +1,183 @@ +/* + * Copyright 2026 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 + * + * https://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.example.platform.ui.haptics.lavabeats + +import android.os.Build +import android.os.VibrationEffect +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Stable + +/** + * A Helper that creates the Lava Beats haptic effect as well as beat parameters + */ +object LavaBeatsHapticHelper { + private const val TAG = "LavaBeatsEffectPlayer" + private const val MIN_BEAT_DELAY_MILLIS = 5f + private const val ENVELOPE_RAMP_DURATION_MILLIS = 5L + + @RequiresApi(Build.VERSION_CODES.BAKLAVA) + fun createEnvelopeBeatEffect(beatParameters: List): HapticBeatEffect { + val effect = createEnvelopeEffect(beatParameters) + val rampsDuration = 4L * ENVELOPE_RAMP_DURATION_MILLIS * beatParameters.getNumBeats() + val duration = rampsDuration + beatParameters.getIntrinsicDurationMillis() + val timingParams = + BeatEffectTimingParams( + beatDurationMillis = + beatParameters.getBaseBeatDurationMillis() + 4 * ENVELOPE_RAMP_DURATION_MILLIS, + timeToFirstPulseMillis = ENVELOPE_RAMP_DURATION_MILLIS.toFloat(), + timeToSecondPulseMillis = + beatParameters.getFirstPulseDurationMillis() + + beatParameters.getFirstToSecondPulseDelayMillis() + + 3 * ENVELOPE_RAMP_DURATION_MILLIS, + beatDelayMillis = beatParameters.getBeatDelayMillis(), + ) + return HapticBeatEffect( + vibrationEffect = effect, + totalDurationEstimateMillis = duration, + beats = beatParameters.getNumBeats(), + timingParams = timingParams, + ) + } + + @RequiresApi(Build.VERSION_CODES.BAKLAVA) + private fun createEnvelopeEffect(beatParameters: List): VibrationEffect = + VibrationEffect.WaveformEnvelopeBuilder() + .apply { + repeat(beatParameters.getNumBeats()) { + // First pulse chirp + addControlPoint( + beatParameters.getFirstPulseAmplitude(), + beatParameters.getFirstPulseStartFreq(), + ENVELOPE_RAMP_DURATION_MILLIS, + ) + addControlPoint( + beatParameters.getFirstPulseAmplitude(), + beatParameters.getFirstPulseEndFreq(), + beatParameters.getFirstPulseDurationMillis().toLong(), + ) + addControlPoint( + 0f, + beatParameters.getFirstPulseEndFreq(), + ENVELOPE_RAMP_DURATION_MILLIS, + ) + // Delay between first and second pulse + addControlPoint( + 0f, + beatParameters.getFirstPulseEndFreq(), + beatParameters.getFirstToSecondPulseDelayMillis().toLong(), + ) + // Second pulse + addControlPoint( + beatParameters.getSecondPulseAmplitude(), + beatParameters.getSecondPulseFreq(), + ENVELOPE_RAMP_DURATION_MILLIS, + ) + addControlPoint( + beatParameters.getSecondPulseAmplitude(), + beatParameters.getSecondPulseFreq(), + (1_000 / (2f * beatParameters.getSecondPulseFreq())).toLong(), + ) + addControlPoint( + 0f, + beatParameters.getSecondPulseFreq(), + ENVELOPE_RAMP_DURATION_MILLIS, + ) + addControlPoint( + 0f, + beatParameters.getSecondPulseFreq(), + beatParameters.getBeatDelayMillis().toLong(), + ) + } + } + .build() + + // Helper functions to idiomatically index the list of parameters + + private fun List.getFirstPulseStartFreq(): Float = this[0].value + + private fun List.getFirstPulseEndFreq(): Float = this[1].value + + private fun List.getFirstPulseDurationMillis(): Float = this[2].value + + private fun List.getFirstPulseAmplitude(): Float = this[3].value + + private fun List.getSecondPulseFreq(): Float = this[4].value + + private fun List.getSecondPulseAmplitude(): Float = this[5].value + + private fun List.getFirstToSecondPulseDelayMillis(): Float = this[6].value + + private fun List.getBpm(): Float = this[7].value + + private fun List.getNumBeats(): Int = this[8].value.toInt() + + private fun List.getBeatDelayMillis(): Float { + val targetValue = + 60_000f / getBpm() - getFirstPulseDurationMillis() - getFirstToSecondPulseDelayMillis() + if (targetValue <= 0) { + Log.e( + TAG, + "Invalid beat delay from selected parameters, returning a minimum of " + + "$MIN_BEAT_DELAY_MILLIS", + ) + return MIN_BEAT_DELAY_MILLIS + } + return targetValue + } + + private fun List.getBaseBeatDurationMillis(): Float = + getBeatDelayMillis() + + getFirstPulseDurationMillis() + + getFirstToSecondPulseDelayMillis() + + (1_000 * 2f / getSecondPulseFreq()) + + private fun List.getIntrinsicDurationMillis(): Float = + getNumBeats() * getBaseBeatDurationMillis() +} + +/** A parameter of a haptic beat effect that represents an EKG signal parameter */ +@Stable +data class BeatParameter( + val description: String = "", + val value: Float = 0f, + val range: ClosedFloatingPointRange = 0f..1f, + val steps: Int = 0, + val isFrequencyType: Boolean = false, +) + +/** Encapsulates the vibration effect of a haptic beat effect with its timing parameters */ +@Stable +data class HapticBeatEffect( + val vibrationEffect: VibrationEffect, + val totalDurationEstimateMillis: Float, + val beats: Int, + val timingParams: BeatEffectTimingParams, +) + +/** Timing parameters of the overall haptic beat effect */ +@Stable +data class BeatEffectTimingParams( + val beatDurationMillis: Float = 0f, + val timeToFirstPulseMillis: Float = 0f, + val timeToSecondPulseMillis: Float = 0f, + val beatDelayMillis: Float = 0f, +) { + companion object { + val Empty = BeatEffectTimingParams() + } +} From 4540461e9e4e2a142468c00bf28603d49ef7fcef Mon Sep 17 00:00:00 2001 From: Juan Sebastian Martinez Date: Thu, 30 Jul 2026 10:43:09 -0700 Subject: [PATCH 2/6] Adding a LavaBeatsViewModel This encapsulates the business logic of playin the haptics beat effect as well as handling the graphical visualization triggers and logic --- .../haptics/lavabeats/LavaBeatsViewModel.kt | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsViewModel.kt diff --git a/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsViewModel.kt b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsViewModel.kt new file mode 100644 index 00000000..c06a74b1 --- /dev/null +++ b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsViewModel.kt @@ -0,0 +1,241 @@ +/* + * Copyright 2026 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 + * + * https://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.example.platform.ui.haptics.lavabeats + +import android.annotation.SuppressLint +import android.app.Application +import android.os.Build +import android.os.Vibrator +import android.os.vibrator.VibratorFrequencyProfile +import androidx.annotation.RequiresApi +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.platform.ui.haptics.R +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * The view model that encapsulates business logic to modify the parameters of the haptic beat + * effect, play the effect, and control the corresponding visualization. + */ +class LavaBeatsViewModel( + val messageToUser: String, + val supportsHapticBeatEffect: Boolean, + private val vibrator: Vibrator, +) : ViewModel() { + + // Haptic beat effect state variables + val beatParameters = mutableStateListOf() + + // This is safely used by checks on supportsBeatHapticEffect + @RequiresApi(Build.VERSION_CODES.BAKLAVA) + private val vibratorFrequencyProfile: VibratorFrequencyProfile? = vibrator.frequencyProfile + + // This is safely checked by supportsBeatHapticEffect + @SuppressLint("NewApi") + private val vibratorFrequencyRange: ClosedFloatingPointRange? = + if (supportsHapticBeatEffect) { + vibratorFrequencyProfile?.let { it.minFrequencyHz..it.maxFrequencyHz } + } else { + null + } + + // This is safely checked by supportsBeatHapticEffect + @SuppressLint("NewApi") + private val beatEffect = derivedStateOf { + if (supportsHapticBeatEffect) { + LavaBeatsHapticHelper.createEnvelopeBeatEffect(beatParameters = beatParameters) + } else { + null + } + } + + val beatTimingParams = derivedStateOf { + beatEffect.value?.timingParams ?: BeatEffectTimingParams.Empty + } + + // UI state control variables + private val _isVibrating = mutableStateOf(false) + val isVibrating: State = _isVibrating + + private val _showVisualization = mutableStateOf(true) + val showVisualization: State = _showVisualization + + private val _showSettings = mutableStateOf(false) + val showSettings: State = _showSettings + + // Visualization pulsing effect variables + private val timeSource = TimeSource.Monotonic + private val _pulseTime = mutableFloatStateOf(0f) + val pulseTime: State = _pulseTime + private var pulseTimeJob: Job? = null + + init { + setDefaultParameters() + updateFrequencyRanges() + } + + fun setDefaultParameters() { + beatParameters.clear() + beatParameters.addAll(DEFAULT_PARAMETERS) + } + + fun updateFrequencyRanges() { + if (vibratorFrequencyRange != null) { + beatParameters.forEachIndexed { i, parameter -> + if (parameter.isFrequencyType) { + val value = parameter.value.coerceIn(vibratorFrequencyRange) + val newParameter = parameter.copy(value = value, range = vibratorFrequencyRange) + beatParameters[i] = newParameter + } + } + } + } + + fun onToggleShowSettings() { + _showSettings.value = !_showSettings.value + } + + fun onToggleVisualization() { + if (supportsHapticBeatEffect) { + _showVisualization.value = !_showVisualization.value + } + } + + fun onSettingChanged(key: Int, settingValue: Float) { + val range = beatParameters[key].range + beatParameters[key] = beatParameters[key].copy(value = settingValue.coerceIn(range)) + } + + fun playHaptics() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + + beatEffect.value?.let { + viewModelScope.launch { + beginPulseTime() + vibrator.vibrate(it.vibrationEffect) + _isVibrating.value = true + delay(it.totalDurationEstimateMillis.toLong().milliseconds) + _isVibrating.value = false + endPulseTime() + } + } + } + + private fun beginPulseTime() { + pulseTimeJob = viewModelScope.launch { + val beginTime = timeSource.markNow() + while (true) { + val time = beginTime.elapsedNow().inWholeMilliseconds + _pulseTime.floatValue = time / 1_000f + delay(10L.milliseconds) + } + } + } + + private fun endPulseTime() { + pulseTimeJob?.cancel() + pulseTimeJob = null + _pulseTime.floatValue = 0f + } + + companion object { + + private val DEFAULT_PARAMETERS = + listOf( + BeatParameter( + description = "First pulse starting frequency (Hz)", + value = 70f, + isFrequencyType = true, + ), + BeatParameter( + description = "First pulse end frequency (Hz)", + value = 90f, + isFrequencyType = true, + ), + BeatParameter( + description = "First pulse duration (ms)", + value = 16f, + range = 1f..500f, + ), + BeatParameter(description = "First pulse amplitude", value = 0.4f, range = 0f..1f), + BeatParameter( + description = "Second pulse frequency (Hz)", + value = 80f, + isFrequencyType = true, + ), + BeatParameter(description = "Second pulse amplitude", value = 0.5f, range = 0f..1f), + BeatParameter( + description = "First to second pulse delay (ms)", + value = 280f, + range = 1f..500f, + ), + BeatParameter( + description = "Beats per minute", + value = 60f, + range = 1f..120f, + steps = 1, + ), + BeatParameter( + description = "Number of beats", + value = 5f, + range = 1f..7f, + steps = 5, + ), + ) + + fun provideFactory(application: Application): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + val vibrator = + ContextCompat.getSystemService( + /*context = */ application, + /*serviceClass = */ Vibrator::class.java, + )!! + + var messageToUser: String + var supportsHapticBeatEffect: Boolean + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA && + vibrator.areEnvelopeEffectsSupported() + ) { + supportsHapticBeatEffect = true + messageToUser = "" + } else { + supportsHapticBeatEffect = false + messageToUser = application.getString(R.string.message_not_supported) + } + + val viewModel = + LavaBeatsViewModel(messageToUser, supportsHapticBeatEffect, vibrator) + + return viewModel as T + } + } + } +} From ee14fae88a5bbe6cc5452a2574a3116f2b2a5bed Mon Sep 17 00:00:00 2001 From: Juan Sebastian Martinez Date: Thu, 30 Jul 2026 11:21:09 -0700 Subject: [PATCH 3/6] Adding the LavaBeatsShader This is the runtime fragment shader that renders the graphical representation of a lava lamp, which beats at the same rythm of the haptic beat effect --- .../ui/haptics/lavabeats/LavaBeatsShader.kt | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsShader.kt diff --git a/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsShader.kt b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsShader.kt new file mode 100644 index 00000000..d1584136 --- /dev/null +++ b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsShader.kt @@ -0,0 +1,205 @@ +/* + * Copyright 2026 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 + * + * https://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.example.platform.ui.haptics.lavabeats + +import android.graphics.RuntimeShader +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import org.intellij.lang.annotations.Language + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +class LavaBeatsShader : RuntimeShader(SHADER) { + fun enablePulsing(enable: Boolean) = + if (enable) { + setFloatUniform("in_pulse", 1f) + } else { + setFloatUniform("in_pulse", 0f) + } + + fun setTime(time: Float) = setFloatUniform("in_time", time) + + fun setResolution(width: Float, height: Float) = setFloatUniform("in_resolution", width, height) + + fun setBeatEffectTimingParameters(effect: BeatEffectTimingParams) { + setFloatUniform("in_pulse_dur_millis", effect.beatDurationMillis) + setFloatUniform("in_time_to_first_pulse_millis", effect.timeToFirstPulseMillis) + setFloatUniform("in_time_to_second_pulse_millis", effect.timeToSecondPulseMillis) + } + + fun setPulseTime(time: Float) { + setFloatUniform("in_pulseTime", time) + } + + fun enableDarkMode() = setFloatUniform("in_theme", 1f) + + fun enableLightMode() = setFloatUniform("in_theme", 0f) + + fun setBackground(color: Color) = setColorUniform("in_background", color.toArgb()) + + companion object { + @Language("AGSL") + private const val SHADER = + """ + uniform half in_pulse_dur_millis; + uniform half in_time_to_first_pulse_millis; + uniform half in_time_to_second_pulse_millis; + uniform half in_pulse; + uniform half in_time; + uniform vec2 in_resolution; + uniform half in_pulseTime; + uniform half in_theme; + layout(color) uniform vec4 in_background; + + const float PI = 3.14159265359; + const float TWO_PI = 6.28318530718; + + // A smooth min function from: https://iquilezles.org/articles/smin/ + float smin(float a, float b, float k) { + k *= 1.0 / (1.0 - sqrt(0.5)); + return max(k, min(a, b)) - length(max(k - vec2(a, b), 0.0)); + } + + // An impulse function + float impulse(float x, float k) { + float h = k * x; + return h * exp(1.0 - h); + } + + float delayedImpulse(float t, float delay, float k) { + return impulse(t - delay, k); + } + + float beatPulses() { + float beatDuration = in_pulse_dur_millis / 1000.; + float normalizedTime = mod(in_pulseTime, beatDuration) / beatDuration; + float firstPulse = + delayedImpulse( + normalizedTime, + in_time_to_first_pulse_millis / in_pulse_dur_millis, + 20. + ); + float secondPulse = + delayedImpulse( + normalizedTime, + in_time_to_second_pulse_millis / in_pulse_dur_millis, + 30. + ); + return max(firstPulse, 0.7 * secondPulse); + } + + float sdParticle(vec2 center, float r0, vec2 uv) { + vec2 p = uv - center; + return length(p) - r0; + } + + float angle(vec2 p) { + float quadrantFactor = step(p.y, 0.0); + return quadrantFactor * PI + acos(((1. - 2. * quadrantFactor) * p.x)/length(p)); + } + + float blob( + vec2 uv, + float T, + float direction, + float timeFactor, + float a, + float n, + float baseR, + float r0 + ) { + float alpha = angle(uv); + float sweepAngle = direction * TWO_PI * mod(timeFactor * in_time, T) / T; + + float centerMagnitude = baseR + a * sin(n * alpha) / 2. + 0.5; + float beta = asin(r0 / centerMagnitude); + vec2 center = centerMagnitude * vec2(cos(sweepAngle), sin(sweepAngle)); + return sdParticle(center, r0 + in_pulse * beatPulses() * 0.1, uv); + } + + float blobs(vec2 uv) { + float T = 5.; + float a = 0.2; + float n = 2.; + float baseR = 0.1; + float r0 = 0.2; + const half maxBlobs = 7.; + float timeFactor = 0.6; + + float currentBlob = 1.; + for (float i = 0.0; i < maxBlobs - 1.; i++) { + float iBaseR = ((i + 1.) / maxBlobs) * baseR; + float iTimeFactor = ((i + 1.) / maxBlobs) * timeFactor; + float iA = ((i + 1.) / maxBlobs) * a; + float iN = i + 1.; + float iR0 = min(((i + 2.) / maxBlobs) * r0, 1.0); + + currentBlob = + smin( + currentBlob, + blob(uv, T, 1.0, iTimeFactor, iA, iN, iBaseR, iR0), + 0.05 + ); + } + + float finalBlob = blob(uv, T, 1.0, 0.6, 0.2, 7., 0.4, 0.3); + currentBlob = smoothstep(0.99, 1., 1. - smin(currentBlob, finalBlob, 0.1)); + return currentBlob; + } + + // A color palette from: https://iquilezles.org/articles/palettes/ + vec3 palette(float t) { + vec3 a = vec3(0.5, 0.5, 0.5); + vec3 b = vec3(0.5, 0.5, 0.5); + vec3 c = vec3(1.0, 1.0, 1.0); + vec3 d = vec3(0., 0.1, 0.2); + return a + b * cos(TWO_PI * (c * t + d)); + } + + vec3 radialColor(vec2 uv) { + float alpha = angle(uv); + float sweepAngle = -TWO_PI * mod(0.8 * in_time, 3.) / 3.; + float angularDifference = mod(alpha - sweepAngle + PI, TWO_PI) - PI; + vec3 angularColor = palette(angularDifference / TWO_PI) + vec3(0.3); + vec3 color = + mix( + angularColor, + vec3(1., 0., 0.), + clamp(0.6 * in_pulse * beatPulses(), 0.1, 0.8) + ); + return color; + } + + vec4 main(in vec2 fragCoord) { + // Normalized pixel coordinates (from -1 to 1) + vec2 centeredCoord = 2.0 * fragCoord.xy - in_resolution.xy; + float minDimension = min(in_resolution.x, in_resolution.y); + vec2 uv = (centeredCoord / minDimension) * 1.35; + + // Blobs + float allBlobs = blobs(uv); + + // Output to screen + vec3 baseColor = radialColor(uv) * allBlobs; + vec3 outColor = in_theme * baseColor + (1. - in_theme) * (1. - baseColor); + vec3 finalColor = mix(in_background.xyz, outColor, allBlobs); + return vec4(finalColor, 1.); + } + """ + } +} From 0ae44f878d8c07c4a9b784c515067089d64511c7 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Martinez Date: Thu, 30 Jul 2026 11:28:38 -0700 Subject: [PATCH 4/6] Adding the LavaBeatsGraphics composable This is the display of the runtime shader --- .../ui/haptics/lavabeats/LavaBeatsGraphics.kt | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsGraphics.kt diff --git a/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsGraphics.kt b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsGraphics.kt new file mode 100644 index 00000000..1f899a27 --- /dev/null +++ b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsGraphics.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2026 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 + * + * https://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.example.platform.ui.haptics.lavabeats + +import android.os.Build +import androidx.compose.animation.core.withInfiniteAnimationFrameMillis +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.platform.LocalDensity + +@Composable +fun LavaBeatsGraphics( + pulseTime: Float, + beatEffectTimingParams: BeatEffectTimingParams, + pulse: Boolean = false, +) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + + BoxWithConstraints { + val constraints = this + val density = LocalDensity.current + val width = with(density) { constraints.maxWidth.toPx() } + val height = with(density) { constraints.maxHeight.toPx() } + var firstTime by remember { mutableFloatStateOf(-1f) } + var time by remember { mutableFloatStateOf(0f) } + val isInDarkMode = isSystemInDarkTheme() + val surfaceColor = MaterialTheme.colorScheme.background + + LaunchedEffect(Unit) { + // Use withInfiniteAnimationFrameMillis to update the time uniform per frame. + // This is a more efficient approach than passing a new shader instance + // or re-creating the RenderEffect on every frame. + while (true) { + withInfiniteAnimationFrameMillis { frameTime -> + if (firstTime == -1f) { + firstTime = frameTime / 1000f + } else { + time = frameTime / 1000f - firstTime + } + } + } + } + val shader = remember { LavaBeatsShader() } + + Box( + modifier = + Modifier.drawWithCache { + if (isInDarkMode) { + shader.enableDarkMode() + } else { + shader.enableLightMode() + } + shader.setBackground(surfaceColor) + shader.enablePulsing(pulse) + shader.setResolution(width, height) + shader.setTime(time) + shader.setPulseTime(pulseTime) + shader.setBeatEffectTimingParameters(beatEffectTimingParams) + val shaderBrush = ShaderBrush(shader) + onDrawBehind { drawRect(shaderBrush) } + } + .fillMaxSize() + ) + } +} From affbbe48290afc0944032418e73bbde1604cd55b Mon Sep 17 00:00:00 2001 From: Juan Sebastian Martinez Date: Thu, 30 Jul 2026 11:40:56 -0700 Subject: [PATCH 5/6] Adding the settings composables for haptic beat parameters These control the haptic effect parameters, which are also used to control the fragment shader visualization timings and beating --- .../ui/haptics/lavabeats/LavaBeatsSettings.kt | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsSettings.kt diff --git a/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsSettings.kt b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsSettings.kt new file mode 100644 index 00000000..8d4d2f52 --- /dev/null +++ b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsSettings.kt @@ -0,0 +1,209 @@ +/* + * Copyright 2026 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 + * + * https://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.example.platform.ui.haptics.lavabeats + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.ArrowDropUp +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp + +@Composable +fun LavaBeatsSettings( + viewModel: LavaBeatsViewModel, + showSettings: Boolean, + onToggleShowSettings: () -> Unit, +) { + Card( + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + ) { + val parameters = viewModel.beatParameters + val isVibrating by viewModel.isVibrating + val supportsPlayback = viewModel.supportsHapticBeatEffect + + Column(modifier = Modifier.padding(8.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Settings", fontWeight = FontWeight.Bold) + IconButton(onClick = onToggleShowSettings) { + val imageVector: ImageVector + val contentDescription: String + if (showSettings) { + imageVector = Icons.Default.ArrowDropUp + contentDescription = "Close settings" + } else { + imageVector = Icons.Default.ArrowDropDown + contentDescription = "Open settings" + } + Icon(imageVector, contentDescription) + } + } + + AnimatedVisibility(showSettings) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + parameters.forEachIndexed { i, parameter -> + key(i) { + LavaBeatsSetting( + settingValue = parameter.value, + settingRange = parameter.range, + steps = parameter.steps, + enabled = !isVibrating && supportsPlayback, + onSettingChange = { newValue -> + viewModel.onSettingChanged(i, newValue) + }, + label = { Text(parameter.description) }, + ) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button( + enabled = !isVibrating, + onClick = { + viewModel.setDefaultParameters() + viewModel.updateFrequencyRanges() + }, + ) { + Text("Reset") + } + } + } + } + } + } +} + +@Composable +fun LavaBeatsSetting( + settingValue: Float, + settingRange: ClosedFloatingPointRange, + steps: Int, + enabled: Boolean, + onSettingChange: (Float) -> Unit, + label: @Composable () -> Unit = { Text("Setting:") }, +) { + val density = LocalDensity.current + val focusManager = LocalFocusManager.current + val isKeyboardVisible = WindowInsets.ime.getBottom(density) > 0 + + var draftText by remember { mutableStateOf(settingValue.toClearFormat()) } + var isTextFieldFocused by remember { mutableStateOf(false) } + + LaunchedEffect(isKeyboardVisible) { + if (!isKeyboardVisible && isTextFieldFocused) { + focusManager.clearFocus() + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier.align(Alignment.CenterVertically).weight(2f), + ) { + label() + } + Slider( + value = settingValue, + onValueChange = { onSettingChange(it) }, + valueRange = settingRange, + steps = steps, + enabled = enabled, + colors = + SliderDefaults.colors( + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.25f), + ), + modifier = Modifier.align(Alignment.CenterVertically).weight(2f), + ) + TextField( + value = if (isTextFieldFocused) draftText else settingValue.toClearFormat(), + onValueChange = { draftText = it }, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + enabled = enabled, + modifier = + Modifier.align(Alignment.CenterVertically).weight(1.5f).onFocusChanged { focusState + -> + if (focusState.isFocused) { + draftText = settingValue.toClearFormat() + isTextFieldFocused = true + } else if (isTextFieldFocused) { + isTextFieldFocused = false + draftText + .toFloatOrNull() + ?.takeIf { it in settingRange } + ?.let { onSettingChange(it) } + } + }, + ) + } +} + +private fun Float.toClearFormat(): String = "%.2f".format(this) From ed8d4abc02268b661088976d85be0d7a95e3430c Mon Sep 17 00:00:00 2001 From: Juan Sebastian Martinez Date: Thu, 30 Jul 2026 12:06:47 -0700 Subject: [PATCH 6/6] Adding the LavaBeatsRoute This finally adds the LavaBeatsScreen and introduces the demo into the samples app --- .../com/example/platform/app/SampleDemo.kt | 10 ++ .../example/platform/ui/haptics/Haptics.kt | 12 ++ .../ui/haptics/lavabeats/LavaBeatsRoute.kt | 136 ++++++++++++++++++ .../haptics/src/main/res/values/strings.xml | 1 + 4 files changed, 159 insertions(+) create mode 100644 samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsRoute.kt diff --git a/app/src/main/java/com/example/platform/app/SampleDemo.kt b/app/src/main/java/com/example/platform/app/SampleDemo.kt index c61fb309..a2757752 100644 --- a/app/src/main/java/com/example/platform/app/SampleDemo.kt +++ b/app/src/main/java/com/example/platform/app/SampleDemo.kt @@ -112,6 +112,7 @@ import com.example.platform.ui.draganddrop.DragAndDropWithViews import com.example.platform.ui.haptics.Bounce import com.example.platform.ui.haptics.Expand import com.example.platform.ui.haptics.HapticsBasic +import com.example.platform.ui.haptics.LavaBeats import com.example.platform.ui.haptics.Resist import com.example.platform.ui.haptics.Wobble import com.example.platform.ui.insets.ImmersiveMode @@ -1055,6 +1056,15 @@ val SAMPLE_DEMOS by lazy { tags = listOf("Haptics"), content = { Wobble() }, ), + ComposableSampleDemo( + id = "haptics-6-lavabeats", + name = "Haptics - 6. LavaBeats", + description = "Demonstrate the complex haptic design of a heartbeat with a lava lamp visualization.", + documentation = "https://source.android.com/docs/core/interaction/haptics", + apiSurface = UserInterfaceHapticsApiSurface, + tags = listOf("Haptics"), + content = { LavaBeats() }, + ), ComposableSampleDemo( id = "live-updates", name = "Live Updates - ProgressStyle implementation", diff --git a/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/Haptics.kt b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/Haptics.kt index 8c40a308..ff879a03 100644 --- a/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/Haptics.kt +++ b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/Haptics.kt @@ -33,6 +33,8 @@ import com.example.platform.ui.haptics.bounce.BounceRoute import com.example.platform.ui.haptics.bounce.BounceViewModel import com.example.platform.ui.haptics.expand.ExpandRoute import com.example.platform.ui.haptics.expand.ExpandViewModel +import com.example.platform.ui.haptics.lavabeats.LavaBeatsRoute +import com.example.platform.ui.haptics.lavabeats.LavaBeatsViewModel import com.example.platform.ui.haptics.resist.ResistRoute import com.example.platform.ui.haptics.resist.ResistViewModel import com.example.platform.ui.haptics.wobble.WobbleRoute @@ -103,3 +105,13 @@ fun Wobble() { ) WobbleRoute(viewModel) } + +@Composable +fun LavaBeats() { + val context = LocalContext.current + val application = context.applicationContext as Application + val viewModel: LavaBeatsViewModel = viewModel( + factory = LavaBeatsViewModel.provideFactory(application), + ) + LavaBeatsRoute(viewModel) +} diff --git a/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsRoute.kt b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsRoute.kt new file mode 100644 index 00000000..f81dc6e4 --- /dev/null +++ b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats/LavaBeatsRoute.kt @@ -0,0 +1,136 @@ +/* + * Copyright 2026 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 + * + * https://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.example.platform.ui.haptics.lavabeats + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.platform.ui.haptics.R +import com.example.platform.ui.haptics.components.Screen + +@Composable +fun LavaBeatsRoute(viewModel: LavaBeatsViewModel) { + LavaBeatsScreen(viewModel) +} + +@Composable +fun LavaBeatsScreen(viewModel: LavaBeatsViewModel) { + val isVibrating by viewModel.isVibrating + val pulseTime by viewModel.pulseTime + val showVisualization by viewModel.showVisualization + val supportsPlayback = viewModel.supportsHapticBeatEffect + val timingParams by viewModel.beatTimingParams + val showSettings by viewModel.showSettings + val animatedShaderFraction by + animateFloatAsState( + targetValue = if (showSettings) 0.35f else 0.7f, + animationSpec = tween(durationMillis = 350), + label = "shaderHeightFraction", + ) + val scrollState = rememberScrollState() + + LaunchedEffect(showSettings) { + if (!showSettings) { + scrollState.animateScrollTo(0) + } + } + + Screen( + pageTitle = stringResource(R.string.lava_beats), + messageToUser = viewModel.messageToUser, + scrollState = scrollState, + ) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val shaderHeight = maxHeight * animatedShaderFraction + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier.fillMaxSize() + .verticalScroll(scrollState) + .padding(top = 16.dp, bottom = 16.dp), + ) { + Box(modifier = Modifier.fillMaxWidth().height(shaderHeight)) { + if (showVisualization) { + LavaBeatsGraphics( + beatEffectTimingParams = timingParams, + pulse = isVibrating, + pulseTime = pulseTime, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Absolute.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = { viewModel.playHaptics() }, + enabled = !isVibrating && supportsPlayback, + ) { + Text("Play Haptics") + } + VisualizationSwitch(viewModel = viewModel) + } + + LavaBeatsSettings( + viewModel, + showSettings, + onToggleShowSettings = viewModel::onToggleShowSettings, + ) + } + } + } +} + +@Composable +fun VisualizationSwitch(viewModel: LavaBeatsViewModel) { + val showVisualization by viewModel.showVisualization + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Toggle visualization:") + Switch( + checked = showVisualization, + onCheckedChange = { viewModel.onToggleVisualization() }, + ) + } +} diff --git a/samples/user-interface/haptics/src/main/res/values/strings.xml b/samples/user-interface/haptics/src/main/res/values/strings.xml index bc7878be..7676a68a 100644 --- a/samples/user-interface/haptics/src/main/res/values/strings.xml +++ b/samples/user-interface/haptics/src/main/res/values/strings.xml @@ -43,4 +43,5 @@ Not supported by your current device. Wobble Drag and release + Haptic Heartbeats