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 0bee52b8..a2d52c0a 100644 --- a/app/src/main/java/com/example/platform/app/SampleDemo.kt +++ b/app/src/main/java/com/example/platform/app/SampleDemo.kt @@ -109,6 +109,8 @@ 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.Resist +import com.example.platform.ui.haptics.Rocket +import com.example.platform.ui.haptics.Spring import com.example.platform.ui.haptics.Wobble import com.example.platform.ui.insets.ImmersiveMode import com.example.platform.ui.insets.WindowInsetsAnimationActivity @@ -1021,6 +1023,24 @@ val SAMPLE_DEMOS by lazy { } }, ), + ComposableSampleDemo( + id = "haptics-6-spring", + name = "Haptics - 6. Spring", + description = "Play Pwle effects to simulate physical world of spring.", + documentation = "https://source.android.com/docs/core/interaction/haptics", + apiSurface = UserInterfaceHapticsApiSurface, + tags = listOf("Haptics"), + content = { Spring() } + ), + ComposableSampleDemo( + id = "haptics-7-rocket", + name = "Haptics - 7. Rocket", + description = "Play Pwle effects of rocket launch.", + documentation = "https://source.android.com/docs/core/interaction/haptics", + apiSurface = UserInterfaceHapticsApiSurface, + tags = listOf("Haptics"), + content = { Rocket() } + ), ActivitySampleDemo( id = "picture-in-picture-video-playback", name = "Picture in Picture (PiP) - Video playback", diff --git a/samples/user-interface/haptics/build.gradle.kts b/samples/user-interface/haptics/build.gradle.kts index 88a2d23a..dbb79e0d 100644 --- a/samples/user-interface/haptics/build.gradle.kts +++ b/samples/user-interface/haptics/build.gradle.kts @@ -22,11 +22,11 @@ plugins { android { namespace = "com.example.platform.ui.haptics" - compileSdk = 35 + compileSdk = 36 defaultConfig { minSdk = 21 - targetSdk = 35 + targetSdk = 36 } kotlinOptions { jvmTarget = "1.8" 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..90709ee0 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 @@ -35,6 +35,10 @@ import com.example.platform.ui.haptics.expand.ExpandRoute import com.example.platform.ui.haptics.expand.ExpandViewModel import com.example.platform.ui.haptics.resist.ResistRoute import com.example.platform.ui.haptics.resist.ResistViewModel +import com.example.platform.ui.haptics.rocket.RocketRoute +import com.example.platform.ui.haptics.rocket.RocketViewModel +import com.example.platform.ui.haptics.spring.SpringRoute +import com.example.platform.ui.haptics.spring.SpringViewModel import com.example.platform.ui.haptics.wobble.WobbleRoute import com.example.platform.ui.haptics.wobble.WobbleViewModel import kotlinx.coroutines.launch @@ -103,3 +107,23 @@ fun Wobble() { ) WobbleRoute(viewModel) } + +@Composable +fun Spring() { + val context = LocalContext.current + val application = context.applicationContext as Application + val viewModel: SpringViewModel = viewModel( + factory = SpringViewModel.provideFactory(application), + ) + SpringRoute(viewModel) +} + +@Composable +fun Rocket() { + val context = LocalContext.current + val application = context.applicationContext as Application + val viewModel: RocketViewModel = viewModel( + factory = RocketViewModel.provideFactory(application), + ) + RocketRoute(viewModel) +} \ No newline at end of file diff --git a/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/rocket/RocketRoute.kt b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/rocket/RocketRoute.kt new file mode 100644 index 00000000..e7987eb3 --- /dev/null +++ b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/rocket/RocketRoute.kt @@ -0,0 +1,235 @@ +/* + * Copyright 2023 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.rocket + +import android.content.res.Resources +import android.os.VibrationEffect +import android.os.Vibrator +import androidx.annotation.RequiresApi +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +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.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.example.platform.ui.haptics.R +import com.example.platform.ui.haptics.components.Screen +import com.example.platform.ui.haptics.modifiers.noRippleClickable + +private val ROCKET_HEIGHT_DP = 80.dp +private val ROCKET_FLAME_HEIGHT_DP = 17.dp +private val ROCKET_WIDTH_DP = 40.dp + +private val FLOOR_HEIGHT_DP = 80.dp + +private const val ANIMATION_LENGTH_MS = 3000 + + + +@Composable +fun RocketRoute(viewModel: RocketViewModel) { + RocketExampleScreen( + viewModel.messageToUser, + viewModel.vibrator, + ) +} + +@Composable +fun RocketExampleScreen(messageToUser: String, vibrator: Vibrator) { + val context = LocalContext.current + // get local density from composable + val density = LocalDensity.current + + //initial height set at 0.dp, everything in Dp until inside DrawRocket + var componentHeight by remember { mutableStateOf(0.dp) } + var componentWidth by remember { mutableStateOf(0.dp) } + + var inFlight by remember { mutableStateOf(false) } + var rocketPositionY by remember { mutableFloatStateOf(0f) } + val animation = remember { Animatable(0f) } + + LaunchedEffect(inFlight) { + if (inFlight) { + // kick off haptic + if (RocketViewModel.isSupportedDevice(context)) { + playEnvelopeVibration( + vibrator, + totalDurationMs = ANIMATION_LENGTH_MS.toLong(), + ) + } + + // animate launch rocket + animation.animateTo( + 1.2f, + animationSpec = tween( + durationMillis = ANIMATION_LENGTH_MS, + // Applies an easing curve with a slow start and rapid acceleration towards the end. + easing = CubicBezierEasing(1f, 0f, 0.75f, 1f), + ), + ) { + rocketPositionY = (componentHeight.value * value).dp.value + } + animation.snapTo(0f) + rocketPositionY = 0f; + inFlight = false; + } + } + + + Screen(pageTitle = stringResource(R.string.rocket), messageToUser = messageToUser) { + Surface( + modifier = Modifier + .noRippleClickable { + if (!inFlight) { + inFlight = true + } + } + // Calculate dimensions of container for Rocket + .onGloballyPositioned { + componentHeight = with(density) { it.size.height.toDp() - FLOOR_HEIGHT_DP } + componentWidth = with(density) { it.size.width.toDp() } + }, + ) { + if (!inFlight) DrawText(stringResource(R.string.rocket_tap_to_launch)) + + DrawRocket(componentHeight, componentWidth, rocketPositionY.dp, inFlight) + DrawFloor(MaterialTheme.colorScheme.primaryContainer, FLOOR_HEIGHT_DP) + } + } +} + + +@Composable +private fun DrawFloor(color: Color, thickness: Dp) { + Box(modifier = Modifier.fillMaxSize()) { + HorizontalDivider( + color = color, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + thickness = thickness, + ) + } +} + +@Composable +private fun DrawText(text: String) { + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = text, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(vertical = 100.dp), + textAlign = TextAlign.Center, + ) + } +} + + +@Composable +private fun DrawRocket( + containerHeightDp: Dp, + containerWidthDp: Dp, + rocketFromFloorDp: Dp, + launched: Boolean, +) { + val rocketX = containerWidthDp / 2 - ROCKET_WIDTH_DP / 2 + val rocketY = containerHeightDp - rocketFromFloorDp - ROCKET_HEIGHT_DP + ROCKET_FLAME_HEIGHT_DP + val resId = if (launched) { + R.drawable.rocket_with_flame + } else { + R.drawable.rocket_without_flame + } + + Image( + modifier = Modifier + .width(ROCKET_WIDTH_DP) + .height(ROCKET_HEIGHT_DP) + .offset(rocketX, rocketY), + painter = painterResource(id = resId), + contentDescription = if (launched) stringResource(R.string.rocket_content_description_launching) else stringResource(R.string.rocket_content_description_idle), + ) +} + + +@RequiresApi(36) +private fun playEnvelopeVibration( + vibrator: Vibrator, + totalDurationMs: Long = 3000L, + targetOutputAccelerationGs: Float = 0.1f, + riseBias: Float = 0.7f, +) { + require(riseBias in 0f..1f) { "Rise bias must be between 0 and 1." } + + if (!vibrator.areEnvelopeEffectsSupported()) { + return + } + + val frequencyProfile = vibrator.frequencyProfile ?: return + val resonantFrequency = vibrator.resonantFrequency + if (resonantFrequency.isNaN()) { + return + } + + val frequencyRange = frequencyProfile.getFrequencyRange(targetOutputAccelerationGs) ?: return + if (frequencyRange.lower >= resonantFrequency) { + return + } + + val rampUpDuration = (riseBias * (totalDurationMs)).toLong() + val rampDownDuration = totalDurationMs - rampUpDuration + + vibrator.vibrate( + VibrationEffect.WaveformEnvelopeBuilder() + .addControlPoint(0.1f, frequencyRange.lower, 20) + .addControlPoint(0.1f, resonantFrequency, rampUpDuration) + .addControlPoint(0.1f, frequencyRange.lower, rampDownDuration) + .addControlPoint(0.0f, frequencyRange.lower, 20) + .build(), + ) +} + diff --git a/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/rocket/RocketViewModel.kt b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/rocket/RocketViewModel.kt new file mode 100644 index 00000000..9f3a284a --- /dev/null +++ b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/rocket/RocketViewModel.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2023 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.rocket + +import android.app.Application +import android.content.Context +import android.os.Build +import android.os.Vibrator +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.example.platform.ui.haptics.R + +/** + * ViewModel that handles state logic for Rocket route. + */ +class RocketViewModel( + val messageToUser: String, + val vibrator: Vibrator, +) : ViewModel() { + + + companion object { + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.BAKLAVA) + fun isSupportedSDK(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA + } + + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.BAKLAVA) + fun isSupportedDevice(context: Context): Boolean { + if (!isSupportedSDK()) return false + val vibrator = ContextCompat.getSystemService(context, Vibrator::class.java) ?: return false + return vibrator.areEnvelopeEffectsSupported() ?: false + } + + /** + * Factory for RocketViewModel. + */ + fun provideFactory( + application: Application, + ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + + var messageToUser = "" + if (!isSupportedDevice(application)) { + messageToUser = application.getString(R.string.message_not_supported) + } + val vibrator = ContextCompat.getSystemService(application, Vibrator::class.java)!! + return RocketViewModel(messageToUser = messageToUser, vibrator = vibrator) as T + } + } + } +} diff --git a/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/spring/SpringRoute.kt b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/spring/SpringRoute.kt new file mode 100644 index 00000000..9694d291 --- /dev/null +++ b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/spring/SpringRoute.kt @@ -0,0 +1,319 @@ +/* + * Copyright 2023 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.spring + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlinx.coroutines.delay + +import android.os.VibrationEffect +import android.os.Vibrator +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +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 +import com.example.platform.ui.haptics.modifiers.noRippleClickable + +private const val GRAVITY = 2f +private const val BOUNCE_DAMPING = 0.85f +private const val INITIAL_VELOCITY = 3.3f +private const val INITIAL_SHARPNESS = 0.2f +private const val INITIAL_INTENSITY = 0.1f +private const val INITIAL_MULTIPLIER = 0.95f +private const val MAX_BOTTOM_BOUNCE = 3 +private const val FRAME_DELAY_MS = 16L +private val FLOOR_HEIGHT = 80.dp +private val SPRING_HEIGHT = 45.dp +private val SPRING_WIDTH = 35.dp + +@Composable +fun SpringRoute(viewModel: SpringViewModel) { + SpringExampleScreen(viewModel.messageToUser, viewModel.vibrator) +} + +@Composable +fun SpringExampleScreen(messageToUser: String, vibrator: Vibrator) { + val context = LocalContext.current + //initial height set at 0.dp + var componentHeight by remember { mutableStateOf(0.dp) } + var componentWidth by remember { mutableStateOf(0.dp) } + + // get local density from composable + val density = LocalDensity.current + + var springX by remember { mutableStateOf(SPRING_WIDTH) } + var springY by remember { mutableStateOf(SPRING_HEIGHT) } + + var velocityX by remember { mutableFloatStateOf(INITIAL_VELOCITY) } + var velocityY by remember { mutableFloatStateOf(INITIAL_VELOCITY) } + var sharpness by remember { mutableFloatStateOf(INITIAL_SHARPNESS) } + var intensity by remember { mutableFloatStateOf(INITIAL_INTENSITY) } + var multiplier by remember { mutableFloatStateOf(INITIAL_MULTIPLIER) } + + var bottomBounceCount by remember { mutableIntStateOf(0) } + var animationStartTime by remember { mutableLongStateOf(0L) } + + // State to trigger animation restart + var animationTrigger by remember { mutableStateOf(false) } + var isAnimating by remember { mutableStateOf(false) } + + val resetAnimation = remember { + { + springX = SPRING_WIDTH + springY = SPRING_HEIGHT + velocityX = INITIAL_VELOCITY + velocityY = INITIAL_VELOCITY + sharpness = INITIAL_SHARPNESS + intensity = INITIAL_INTENSITY + multiplier = INITIAL_MULTIPLIER + bottomBounceCount = 0 + animationStartTime = System.currentTimeMillis() + isAnimating = false + animationTrigger = !animationTrigger // Toggle the trigger + } + } + + + + LaunchedEffect(animationTrigger) { + animationStartTime = System.currentTimeMillis() + isAnimating = true + + while (isAnimating) { + velocityY += GRAVITY + springX += velocityX.dp + springY += velocityY.dp + + // Handle bottom collision + if (springY > componentHeight - FLOOR_HEIGHT - SPRING_HEIGHT / 2) { + // Set the spring's Y position to the bottom bounce point, ensuring it remains above the floor. + springY = componentHeight - FLOOR_HEIGHT - SPRING_HEIGHT / 2 + // Reverse the vertical velocity and apply damping to simulate a bounce. + velocityY *= -BOUNCE_DAMPING + bottomBounceCount++ + + if (SpringViewModel.isSupportedOnDevice(context)) { + playEnvelopeVibration(vibrator, velocityY, intensity, sharpness) + } + + // Decrease the intensity and sharpness of the vibration for subsequent bounces, + // and reduce the multiplier to create a fading effect. + intensity *= multiplier + sharpness *= multiplier + //Log.e("SPRING","$velocityY, $intensity, $sharpness, $multiplier") + multiplier = (multiplier - 0.1f).coerceAtLeast(0f) + } + + if (springX > componentWidth - SPRING_WIDTH / 2) { + // Prevent the spring from moving beyond the right edge of the screen. + springX = componentWidth - SPRING_WIDTH / 2 + } + + // Check for 3 bottom bounces and then slow down + if (bottomBounceCount >= MAX_BOTTOM_BOUNCE && System.currentTimeMillis() - animationStartTime > 1000) { + velocityX *= 0.9f + velocityY *= 0.9f + } + + delay(FRAME_DELAY_MS) // Control animation speed + + // Determine if the animation should continue based on the spring's position and velocity. + isAnimating = + (springY < componentHeight + SPRING_HEIGHT || springX < componentWidth + SPRING_WIDTH) + && (velocityX >= 0.1f || velocityY >= 0.1f) + } + } + + Screen(pageTitle = stringResource(R.string.spring), messageToUser = messageToUser) { + Surface( + modifier = Modifier + //.border(2.dp, Color.Green) + .onGloballyPositioned { + componentHeight = with(density) { + it.size.height.toDp() + } + componentWidth = with(density) { + it.size.width.toDp() + } + } + .fillMaxWidth(), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .noRippleClickable { + if (!isAnimating) { + resetAnimation() + } + } + .width(componentWidth) + .height(componentHeight) + .background(Color.Transparent), + ) { + if (!isAnimating) { + DrawText(stringResource(R.string.spring_tap_to_restart)) + } + DrawSpring(MaterialTheme.colorScheme.primaryContainer, springX, springY) + DrawFloor(MaterialTheme.colorScheme.primaryContainer) + } + } + } +} + +@Composable +private fun DrawText(text: String) { + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = text, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(vertical = 100.dp), + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun DrawFloor(color: Color) { + Box(modifier = Modifier.fillMaxSize()) { + HorizontalDivider( + color = color, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + thickness = FLOOR_HEIGHT, + ) + } +} + + +@RequiresApi(36) +private fun playEnvelopeVibration( + vibrator: Vibrator, + velocityY: Float, + intensity: Float, + sharpness: Float, +) { + if (!vibrator.areEnvelopeEffectsSupported()) return + + require(sharpness in 0f..1f) { "Sharpness must be between 0 and 1." } + require(intensity in 0f..1f) { "intensity must be between 0 and 1." } + + val minControlPointDurationMs = vibrator.envelopeEffectInfo.minControlPointDurationMillis ?: return + + // Calculate the fade-out duration of the vibration based on the vertical velocity. + val fadeOutDuration = ((abs(velocityY) / GRAVITY) * FRAME_DELAY_MS).toLong() + // Create a <> envelope vibration effect that fades out. + vibrator.vibrate( + VibrationEffect.BasicEnvelopeBuilder() + // Starting from zero sharpness here, will simulate a smoother <> effect. + .setInitialSharpness(0f) + // Add a control point to reach the desired intensity and sharpness as quickly as possible + .addControlPoint(intensity, sharpness, minControlPointDurationMs) + // Add a control point to fade out the vibration intensity while maintaining sharpness. + .addControlPoint(0f, sharpness, fadeOutDuration).build(), + ) +} + +/** + * I hope there's a better way to create a spring-like shape. + */ +@Composable +private fun DrawSpring(color: Color, springX: Dp, springY: Dp) { + Canvas(modifier = Modifier.fillMaxSize()) { + val numberOfCoils = 5 // Adjust for desired coil density + val coilResolution = 20 // Points per coil, adjust for smoothness + + // Calculate spring parameters + val x1 = springX.toPx() + val y1 = springY.toPx() - SPRING_HEIGHT.toPx() / 2 // Start at top of spring + val x2 = springX.toPx() + val y2 = springY.toPx() + SPRING_HEIGHT.toPx() / 2 // End at bottom of spring + val width = SPRING_WIDTH.toPx() + val x = x2 - x1 + val y = y2 - y1 + val dist = kotlin.math.sqrt(x * x + y * y) + val nx = y / dist // Normal vector components (swapped for correct direction) + val ny = -x / dist + val coilSpacing = dist / numberOfCoils + + // Draw the spring + val path = Path() + path.moveTo(x1, y1) + + for (i in 0..numberOfCoils) { + val coilStart = i * coilSpacing + for (j in 0..coilResolution) { + val angle = j * 2 * Math.PI / coilResolution + val positionAlongSpring = coilStart + (coilSpacing * j / coilResolution) + + val xx = x1 + (x * positionAlongSpring / dist) + + kotlin.math.cos(angle) * nx * width / 2 + val yy = y1 + (y * positionAlongSpring / dist) + + kotlin.math.cos(angle) * ny * width / 2 + + path.lineTo(xx.toFloat(), yy.toFloat()) + } + } + + drawPath( + path = path, + color = color, + style = Stroke(width = 5f, cap = StrokeCap.Round), + ) + } +} diff --git a/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/spring/SpringViewModel.kt b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/spring/SpringViewModel.kt new file mode 100644 index 00000000..b7c7fe27 --- /dev/null +++ b/samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/spring/SpringViewModel.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2023 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.spring + +import android.app.Application +import android.content.Context +import android.os.Build +import android.os.Vibrator +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.example.platform.ui.haptics.R + +/** + * ViewModel that handles state logic for Spring route. + */ +class SpringViewModel( + val messageToUser: String, + val vibrator: Vibrator, +) : ViewModel() { + + companion object { + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.BAKLAVA) + fun isSupportedSDK(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA + } + + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.BAKLAVA) + fun isSupportedOnDevice(context: Context): Boolean { + if (!isSupportedSDK()) return false + val vibrator = ContextCompat.getSystemService(context, Vibrator::class.java) ?: return false + return vibrator.areEnvelopeEffectsSupported() ?: false + } + + /** + * Factory for SpringViewModel. + */ + fun provideFactory( + application: Application, + ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + var messageToUser = "" + if (!isSupportedOnDevice(application)) { + messageToUser = application.getString(R.string.message_not_supported) + } + val vibrator = ContextCompat.getSystemService(application, Vibrator::class.java) ?: error("Vibrator service not available") + return SpringViewModel(messageToUser = messageToUser, vibrator = vibrator) as T + } + } + } + +} diff --git a/samples/user-interface/haptics/src/main/res/drawable/rocket_with_flame.png b/samples/user-interface/haptics/src/main/res/drawable/rocket_with_flame.png new file mode 100644 index 00000000..38afd9dc Binary files /dev/null and b/samples/user-interface/haptics/src/main/res/drawable/rocket_with_flame.png differ diff --git a/samples/user-interface/haptics/src/main/res/drawable/rocket_without_flame.png b/samples/user-interface/haptics/src/main/res/drawable/rocket_without_flame.png new file mode 100644 index 00000000..c20d5e72 Binary files /dev/null and b/samples/user-interface/haptics/src/main/res/drawable/rocket_without_flame.png differ 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..c8061626 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,12 @@ Not supported by your current device. Wobble Drag and release + Spring + Tap to restart + Rocket + Tap to launch + Rocket launching + Rocket idle + +