이 페이지에서는 다양한 촉각 API를 사용하여 Android 앱에서 표준 진동 파형 외에 맞춤 효과를 만드는 방법의 예를 다룹니다.
이 페이지에는 다음 예시가 포함되어 있습니다.
VibrationEffect.Builder를 사용한 타임라인 고정 컴포지션- 사전 설정으로 구성: 사전 정의된 햅틱 감각을 순서대로 정렬합니다.
- 엔벨로프 및 사전 설정으로 구성: 타임라인을 따라 엔벨로프와 사전 설정을 결합합니다.
- 이벤트 재사용 및 이동: 기존 컴포지션 이벤트를 이동하고 재사용합니다.
- 반복되는 컴포지션: 반복되는 타임라인 효과를 만듭니다.
- 맞춤 진동 패턴
- 진동 기본 요소 컴포지션
- 엔벨로프가 있는 진동 파형
추가 예는 이벤트에 햅틱 반응 추가를 참고하고 항상 햅틱 디자인 원칙을 따르세요.
대체 기능을 사용하여 기기 호환성 처리
맞춤 촉각 효과를 구현할 때 기기 호환성 및 대체 동작은 선택한 API 노출 영역에 따라 달라집니다.
VibrationEffect.Builder(권장): Android 16(26Q4)부터VibrationEffect.Builder를 사용하여 만든 효과에는 자동 프레임워크 수준 대체가 포함됩니다. 기기가 요청된Preset또는 기본Envelope를 기본적으로 지원하지 않는 경우 프레임워크는 재생 시 최선을 다해 적절한 대안으로 자동 변환합니다.VibrationEffect.Builder로 구성된 효과를 재생하기 전에 기본 요소별 기기 기능을 수동으로 확인할 필요가 없습니다.- 예외:
WaveformEnvelopeBuilder로 생성된 고급 파형 엔벨로프는 특정 하드웨어 주파수 매핑(FOAM)에 따라 달라지므로 자동 대체를 지원하지 않습니다. 지원되지 않는 경우 재생되지 않습니다.
- 예외:
VibrationEffect.Composition:startComposition()API로 만든 컴포지션에는 자동 대체가 없습니다. 컴포지션에 지원되지 않는 기본 요소가 하나라도 포함되어 있으면 전체 진동이 재생되지 않습니다.vibrator.arePrimitivesSupported()를 사용하여 기능을 수동으로 확인해야 합니다.- 진폭 제어가 있는 파형: 진폭 제어가 없는 기기에서는 0이 아닌 진폭이 100% 로 반올림됩니다.
vibrator.hasAmplitudeControl()를 확인하고 필요한 경우 명시적으로 설계된 ON/OFF 패턴으로 대체합니다.
햅틱 프리미티브 사용
Android에는 진폭과 주파수가 모두 다른 여러 햅틱 프리미티브가 포함되어 있습니다. 하나의 프리미티브를 단독으로 사용하거나 여러 프리미티브를 조합하여 풍부한 햅틱 효과를 얻을 수 있습니다.
- 두 기본 요소 간에 눈에 띄는 간격을 두려면 50ms 이상의 지연을 사용하고, 가능한 경우 기본 요소 지속 시간도 고려합니다.
- 강도의 차이를 더 잘 인식할 수 있도록 1.4 이상의 비율로 다른 배율을 사용합니다.
0.5, 0.7, 1.0의 배율을 사용하여 기본 요소의 강도가 낮음, 중간, 높음인 버전을 만듭니다.
맞춤 진동 패턴 만들기
진동 패턴은 알림 및 벨소리와 같은 주의 촉각에 자주 사용됩니다. Vibrator 서비스는 시간이 지남에 따라 진동 진폭을 변경하는 긴 진동 패턴을 재생할 수 있습니다. 이러한 효과를 파형이라고 합니다.
파형 효과는 일반적으로 인지할 수 있지만 갑작스러운 긴 진동은 조용한 환경에서 재생될 경우 사용자를 놀라게 할 수 있습니다. 목표 진폭으로 너무 빠르게 램핑하는 경우에도 청각적으로 윙윙거리는 노이즈가 생성될 수 있습니다. 진폭 전환을 원활하게 하여 램프 업 및 다운 효과를 만드는 파형 패턴을 설계합니다.
진동 패턴의 예
다음 섹션에서는 진동 패턴의 몇 가지 예를 제공합니다.
단계적 확대 패턴
파형은 세 가지 매개변수가 있는 VibrationEffect로 표현됩니다.
- 타이밍: 각 파형 세그먼트의 지속 시간(밀리초) 배열입니다.
- 진폭: 첫 번째 인수에 지정된 각 기간에 원하는 진동 진폭으로, 0에서 255 사이의 정수 값으로 표시됩니다. 여기서 0은 진동기 '꺼짐 상태'를 나타내고 255는 기기의 최대 진폭입니다.
- 반복 색인: 파형 반복을 시작하기 위해 첫 번째 인수에 지정된 배열의 색인입니다. 패턴을 한 번만 재생해야 하는 경우 -1입니다.
다음은 펄스 사이에 350ms의 일시중지가 있는 두 번의 펄스 파형 예입니다. 첫 번째 펄스는 최대 진폭까지 부드럽게 증가하고 두 번째 펄스는 최대 진폭을 유지하기 위해 빠르게 증가합니다. 끝에서 중지하는 것은 음수 반복 색인 값으로 정의됩니다.
val timings: LongArray = longArrayOf( 50, 50, 50, 50, 50, 100, 350, 25, 25, 25, 25, 200 ) val amplitudes: IntArray = intArrayOf( 33, 51, 75, 113, 170, 255, 0, 38, 62, 100, 160, 255 ) val repeatIndex = -1 // Don't repeat. vibrator.vibrate( VibrationEffect.createWaveform( timings, amplitudes, repeatIndex ) )
반복 패턴
파형은 취소될 때까지 반복적으로 재생될 수도 있습니다. 반복 파형을 만드는 방법은 음수가 아닌 repeat 매개변수를 설정하는 것입니다. 반복 파형을 재생하면 서비스에서 명시적으로 취소될 때까지 진동이 계속됩니다.
fun startVibrating() { val timings: LongArray = longArrayOf(50, 50, 100, 50, 50) val amplitudes: IntArray = intArrayOf(64, 128, 255, 128, 64) val repeat = 1 // Repeat from the second entry, index = 1. val repeatingEffect: VibrationEffect = VibrationEffect.createWaveform( timings, amplitudes, repeat ) // repeatingEffect can be used in multiple places. vibrator.vibrate(repeatingEffect) } fun stopVibrating() { vibrator.cancel() }
이는 사용자가 확인해야 하는 간헐적인 이벤트에 매우 유용합니다. 이러한 이벤트의 예로는 수신 전화 및 트리거된 알람이 있습니다.
대체 패턴
진동의 진폭을 제어하는 것은 하드웨어 종속 기능입니다. 이 기능이 없는 저사양 기기에서 파형을 재생하면 진폭 배열의 각 양수 항목에 대해 기기가 최대 진폭으로 진동합니다. 앱에서 이러한 기기를 수용해야 하는 경우 해당 조건에서 재생될 때 버징 효과를 생성하지 않는 패턴을 사용하거나 대신 대체로 재생할 수 있는 더 간단한 ON/OFF 패턴을 설계합니다.
if (vibrator.hasAmplitudeControl()) { vibrator.vibrate( VibrationEffect.createWaveform( smoothTimings, amplitudes, smoothRepeatIdx ) ) } else { vibrator.vibrate( VibrationEffect.createWaveform( onOffTimings, onOffRepeatIdx ) ) }
VibrationEffect.Builder를 사용한 타임라인 고정 컴포지션
Android 16 (26Q4)부터 VibrationEffect.Builder는 복잡한 진동 효과와 컴포지션을 만드는 데 선호되는 API입니다. 이를 통해 startTimeMillis를 사용하여 절대 타임라인을 따라 개별 햅틱 요소를 시퀀싱하여 표현력이 풍부한 햅틱 감각을 구성할 수 있습니다.
VibrationEffect.Builder는 여러 진동 유형을 결합하는 것을 지원합니다.
- 사전 설정: 클릭 및 틱과 같이 사전 정의된 햅틱 펄스 (
VibrationEffect.Preset)입니다. - 엔벨로프: 하드웨어에 구애받지 않는 기본 엔벨로프 (
BasicEnvelopeBuilder) 및 고급 주파수 변조 엔벨로프 (WaveformEnvelopeBuilder)를 포함한 동적 연속 파형입니다. - 기존 VibrationEffects:
addEvents()또는 생성자 복사를 사용하여 가져온 기본 요소 컴포지션(VibrationEffect.Composition), 단계 파형(VibrationEffect.createWaveform), 사전 정의된 효과(VibrationEffect.createPredefined), 원샷(VibrationEffect.createOneShot) - 반복 시퀀스:
setRepeatingEffect()로 구성된 연속 패턴입니다.
VibrationEffect.Builder로 빌드된 진동에는 요소 (사전 설정, 기본 엔벨로프, 결합된 진동 효과 포함) 전반에 걸쳐 프레임워크 수준의 자동 대체가 내장되어 있어 수동 기능 검사 없이도 여러 기기에서 일관된 사용자 환경을 보장합니다. (WaveformEnvelopeBuilder로 생성된 고급 파형 엔벨로프는 하드웨어 지원이 필요하며 자동 대체 지원이 없습니다.)
사전 설정으로 작성
VibrationEffect.Preset를 사용하여 일반적인 사전 정의된 짧은 햅틱 펄스(예: PRESET_CLICK, PRESET_TICK, PRESET_LOW_TICK)를 컴포지션에 추가합니다.
사전 설정은 기기 제조업체에서 구현하며, 명확한 햅틱을 위한 햅틱 원칙에 부합하는 선명하고 짧으며 기분 좋은 진동을 제공합니다. 이러한 기능과 작동 방식에 관한 자세한 내용은 진동 액추에이터 입문서를 참고하세요.
사전 설정은 VibrationEffect.Composition API의 짧은 프리미티브를 대체합니다.
점점 강해지거나 약해지는 등 더 길거나 연속적인 햅틱 감각의 경우 엔벨로프 파형 (PWLE)을 대신 사용하세요.
각 사전 설정에는 0.0f에서 1.0f 사이의 선택적 배율을 할당할 수 있으며, 컴포지션 시작부터 명시적인 시작 시간 (밀리초)에 배치됩니다.
Kotlin
val clickPreset = VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_CLICK, /* scale= */ 0.8f
)
val tickPreset = VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_TICK, /* scale= */ 0.5f
)
val effect = VibrationEffect.Builder()
.addPreset(/* startTimeMillis= */ 0L, clickPreset)
.addPreset(/* startTimeMillis= */ 100L, tickPreset)
.build()
vibrator.vibrate(effect)
자바
VibrationEffect.Preset clickPreset = VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_CLICK, /* scale= */ 0.8f
);
VibrationEffect.Preset tickPreset = VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_TICK, /* scale= */ 0.5f
);
VibrationEffect effect = new VibrationEffect.Builder()
.addPreset(/* startTimeMillis= */ 0L, clickPreset)
.addPreset(/* startTimeMillis= */ 100L, tickPreset)
.build();
vibrator.vibrate(effect);
엔벨로프 및 프리셋으로 작곡하기
VibrationEffect.Envelope 인스턴스 (BasicEnvelopeBuilder 또는 WaveformEnvelopeBuilder를 사용하여 생성됨)를 사전 설정과 원활하게 결합하여 풍부한 다중 세그먼트 햅틱 패턴을 만들 수 있습니다.
다음은 부드러운 램프업 및 페이드아웃 엔벨로프를 재생한 후 날카로운 클릭 사전 설정을 재생하는 예입니다.
Kotlin
val basicEnvelope = VibrationEffect.Envelope.create(
VibrationEffect.BasicEnvelopeBuilder()
.setInitialSharpness(0.0f)
.addControlPoint(1.0f, 1.0f, 300L)
.addControlPoint(0.0f, 0.5f, 100L)
)
val clickPreset = VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_CLICK, 0.9f
)
val mixedEffect = VibrationEffect.Builder()
.addEnvelope(/* startTimeMillis= */ 0L, basicEnvelope)
.addPreset(/* startTimeMillis= */ 450L, clickPreset)
.build()
vibrator.vibrate(mixedEffect)
자바
VibrationEffect.Envelope basicEnvelope = VibrationEffect.Envelope.create(
new VibrationEffect.BasicEnvelopeBuilder()
.setInitialSharpness(0.0f)
.addControlPoint(1.0f, 1.0f, 300L)
.addControlPoint(0.0f, 0.5f, 100L)
);
VibrationEffect.Preset clickPreset = VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_CLICK, 0.9f
);
VibrationEffect mixedEffect = new VibrationEffect.Builder()
.addEnvelope(/* startTimeMillis= */ 0L, basicEnvelope)
.addPreset(/* startTimeMillis= */ 450L, clickPreset)
.build();
vibrator.vibrate(mixedEffect);
기존 일정 재사용 및 이동하기
기존 VibrationEffect (VibrationEffect.Composition 포함)를 재사용하거나 연결하려면 getEvents()를 사용하여 VibrationEffect.Event 객체 목록을 가져오고 addEvents(startTimeShiftMillis, events)를 사용하여 오프셋과 함께 추가합니다 (또는 효과를 VibrationEffect.Builder(effect) 생성자에 직접 전달). 이 방법으로 VibrationEffect.Composition 인스턴스를 가져오면 프레임워크가 자동으로 프리미티브를 사전 설정으로 변환하여 런타임 대체 지원을 사용 설정합니다.
Kotlin
val existingEffect = VibrationEffect.Builder()
.addPreset(
0L,
VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_CLICK)
)
.addPreset(
80L,
VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_TICK)
)
.build()
// Shift and append the existing events 200ms into the new composition.
val combinedEffect = VibrationEffect.Builder()
.addEvents(/* startTimeShiftMillis= */ 200L, existingEffect.events)
.build()
vibrator.vibrate(combinedEffect)
자바
VibrationEffect existingEffect = new VibrationEffect.Builder()
.addPreset(
0L,
VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_CLICK)
)
.addPreset(
80L,
VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_TICK)
)
.build();
// Shift and append the existing events 200ms into the new composition.
VibrationEffect combinedEffect = new VibrationEffect.Builder()
.addEvents(/* startTimeShiftMillis= */ 200L, existingEffect.getEvents())
.build();
vibrator.vibrate(combinedEffect);
반복 컴포지션 만들기
setRepeatingEffect(startTimeMillis, repeatingEffect, durationMillis)를 사용하여 컴포지션에 반복 패턴을 추가합니다.
Kotlin
val repeatingPattern = VibrationEffect.Builder()
.addPreset(
0L,
VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_CLICK, 1.0f
)
)
.addPreset(
150L,
VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_LOW_TICK, 0.6f
)
)
.build()
val repeatingEffect = VibrationEffect.Builder()
.setRepeatingEffect(
/* startTimeMillis= */ 0L,
/* effect= */ repeatingPattern,
/* durationMillis= */ 300L
)
.build()
vibrator.vibrate(repeatingEffect)
자바
VibrationEffect repeatingPattern = new VibrationEffect.Builder()
.addPreset(
0L,
VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_CLICK, 1.0f
)
)
.addPreset(
150L,
VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_LOW_TICK, 0.6f
)
)
.build();
VibrationEffect repeatingEffect = new VibrationEffect.Builder()
.setRepeatingEffect(
/* startTimeMillis= */ 0L,
/* effect= */ repeatingPattern,
/* durationMillis= */ 300L
)
.build();
vibrator.vibrate(repeatingEffect);
타이밍, 검증, 드리프트 관리
VibrationEffect.Builder로 컴포지션을 설계할 때는 다음 타이밍 및 유효성 검사 규칙에 유의하세요.
- 엄격하게 증가하는 시작 시간: 빌더에 추가된 모든 요소에는 이전 요소의 시작 시간보다 엄격하게 크거나 같은
startTimeMillis가 있어야 합니다. - 빌드 시간 검증: 빌더는 알려진 요소 기간 (또는 사전 설정의 경우 최소 1ms)을 사용하여
build()시간에 최선의 검증을 수행합니다. 불가능한 중복이 감지되면IllegalArgumentException가 발생합니다. - 재생 순차적 전환: 다음 요소의 시작 시간이 되었을 때 이전 진동 요소가 여전히 물리적으로 재생 중이면 프레임워크는 다음 요소를 사용 가능한 가장 빠른 시간 슬롯으로 자동 전환합니다. 이렇게 하면 이벤트가 겹치지 않고 진동이 누락되지 않지만, 이벤트가 너무 가깝게 예약된 경우 약간의 타이밍 드리프트가 발생할 수 있습니다. 드리프트를 최소화하려면 연속된 햅틱 이벤트 사이에 충분한 시간 (예: 50ms 이상)을 허용합니다.
진동 기본 요소 조합 만들기
이 섹션에서는 VibrationEffect.Composition를 사용하여 진동을 구성하는 방법을 설명합니다. 이 페이지 앞부분에서 설명한 컴포지션 기본 요소는 기기 제조업체에서 구현합니다. 명확한 햅틱을 위한 햅틱 원칙에 부합하는 선명하고 짧으며 기분 좋은 진동을 제공합니다. 이러한 기능과 작동 방식에 관한 자세한 내용은 진동 액추에이터 입문서를 참고하세요.
VibrationEffect.Builder와 달리 VibrationEffect.Composition API에는 지원되지 않는 프리미티브에 대한 자동 대체가 없습니다. 다음과 같습니다.
고급 햅틱을 활성화하기 전에 특정 기기가 사용 중인 모든 프리미티브를 지원하는지 확인하세요.
기본 요소가 누락된 효과뿐만 아니라 지원되지 않는 일관된 환경 집합을 사용 중지합니다.
- Kotlin:
val fallbackEffect = VibrationEffect.Builder(compositionEffect).build()(또는.addEvents(0L, compositionEffect.events)) - Java:
VibrationEffect fallbackEffect =new VibrationEffect.Builder(compositionEffect).build();(또는.addEvents(0L, compositionEffect.getEvents()))
VibrationEffect.Builder로 구성된 경우 프레임워크는 컴포지션 프리미티브를 사전 설정으로 변환하고, 사용자의 기기에서 지원되지 않는 프리미티브가 있으면 런타임 대체 기능을 자동으로 제공합니다.
구성된 진동 효과
VibrationEffect.Composition를 사용하여 구성된 진동 효과를 만들 수 있습니다. 다음은 서서히 상승하는 효과와 그 뒤를 잇는 날카로운 클릭 효과의 예입니다.
vibrator.vibrate( VibrationEffect.startComposition().addPrimitive( VibrationEffect.Composition.PRIMITIVE_SLOW_RISE ).addPrimitive( VibrationEffect.Composition.PRIMITIVE_CLICK ).compose() )
컴포지션은 순서대로 재생할 기본 요소를 추가하여 만듭니다. 각 프리미티브는 확장 가능하므로 각 프리미티브에서 생성되는 진동의 진폭을 제어할 수 있습니다. 배율은 0과 1 사이의 값으로 정의되며, 여기서 0은 실제로 이 프리미티브를 사용자가 (거의) 느낄 수 있는 최소 진폭에 매핑됩니다.
진동 기본 요소에서 변형 만들기
동일한 프리미티브의 약한 버전과 강한 버전을 만들려면 강도 비율을 1.4 이상으로 만들어 강도 차이를 쉽게 인식할 수 있도록 합니다. 동일한 기본 요소의 강도 수준을 3개 이상 만들려고 하지 마세요. 지각적으로 구별되지 않기 때문입니다. 예를 들어 0.5, 0.7, 1.0의 배율을 사용하여 기본 요소의 저강도, 중강도, 고강도 버전을 만듭니다.
진동 프리미티브 사이에 간격 추가
컴포지션은 연속된 프리미티브 사이에 추가할 지연을 지정할 수도 있습니다. 이 지연 시간은 이전 프리미티브가 종료된 후 밀리초 단위로 표시됩니다. 일반적으로 두 프리미티브 사이의 5~10ms 간격은 너무 짧아서 감지할 수 없습니다. 두 기본 요소 사이에 눈에 띄는 간격을 만들려면 50ms 이상의 간격을 사용하세요. 다음은 지연이 있는 컴포지션의 예입니다.
val delayMs = 100 vibrator.vibrate( VibrationEffect.startComposition().addPrimitive( VibrationEffect.Composition.PRIMITIVE_SPIN, 0.8f ).addPrimitive( VibrationEffect.Composition.PRIMITIVE_SPIN, 0.6f ).addPrimitive( VibrationEffect.Composition.PRIMITIVE_THUD, 1.0f, delayMs ).compose() )
지원되는 기본 요소 확인
다음 API를 사용하여 기기에서 특정 프리미티브를 지원하는지 확인할 수 있습니다.
val primitive = VibrationEffect.Composition.PRIMITIVE_LOW_TICK if (vibrator.areAllPrimitivesSupported(primitive)) { vibrator.vibrate( VibrationEffect.startComposition() .addPrimitive(primitive).compose() ) } else { // Play a predefined effect or custom pattern as a fallback. }
여러 기본 요소를 확인한 후 기기 지원 수준에 따라 구성할 기본 요소를 결정할 수도 있습니다.
val primitives: IntArray = intArrayOf( VibrationEffect.Composition.PRIMITIVE_LOW_TICK, VibrationEffect.Composition.PRIMITIVE_TICK, VibrationEffect.Composition.PRIMITIVE_CLICK ) val supported: BooleanArray = vibrator.arePrimitivesSupported(*primitives)
진동 구성 예시
다음 섹션에서는 GitHub의 햅틱 샘플 앱에서 가져온 진동 구성의 몇 가지 예를 제공합니다.
저항 (낮은 틱)
진행 중인 작업에 유용한 피드백을 전달하기 위해 기본 진동의 진폭을 제어할 수 있습니다. 간격이 좁은 배율 값을 사용하여 프리미티브의 부드러운 크레센도 효과를 만들 수 있습니다. 연속된 기본 요소 간의 지연도 사용자 상호작용에 따라 동적으로 설정할 수 있습니다. 이는 드래그 동작으로 제어되고 햅틱으로 보강된 뷰 애니메이션의 다음 예에 나와 있습니다.
그림 1. 이 파형은 기기에서 진동의 출력 가속도를 나타냅니다.
@Composable fun ResistScreen() { // Control variables for the dragging of the indicator. var isDragging by remember { mutableStateOf(false) } var dragOffset by remember { mutableFloatStateOf(0f) } // Only vibrates while the user is dragging LaunchedEffect(isDragging) { // Continuously run the effect for vibration to occur even when the view // is not being drawn, when user stops dragging midway through gesture. while (isDragging) { // Calculate the interval inversely proportional to the drag offset. val vibrationInterval = calculateVibrationInterval(dragOffset) // Calculate the scale directly proportional to the drag offset. val vibrationScale = calculateVibrationScale(dragOffset) delay(vibrationInterval) vibrator.vibrate( VibrationEffect.startComposition().addPrimitive( VibrationEffect.Composition.PRIMITIVE_LOW_TICK, vibrationScale ).compose() ) } } Column( Modifier .draggable( orientation = Orientation.Vertical, onDragStarted = { isDragging = true }, onDragStopped = { isDragging = false }, state = rememberDraggableState { delta -> dragOffset += delta } ) ) { // Build the indicator UI based on how much the user has dragged it. ResistIndicator(dragOffset) } }
확장 (상승 및 하락 포함)
인지되는 진동 강도를 높이는 기본 요소에는 PRIMITIVE_QUICK_RISE와 PRIMITIVE_SLOW_RISE 두 가지가 있습니다. 두 캠페인 모두 동일한 타겟에 도달하지만 기간이 다릅니다. 램프 다운을 위한 기본 요소는 PRIMITIVE_QUICK_FALL 하나뿐입니다. 이러한 프리미티브는 함께 작동하여 강도가 증가했다가 사라지는 파형 세그먼트를 만듭니다. 확장된 프리미티브를 정렬하여 프리미티브 간의 진폭이 갑자기 증가하는 것을 방지할 수 있으며, 이는 전체 효과 지속 시간을 연장하는 데도 효과적입니다.
사람들은 항상 하강 부분보다 상승 부분을 더 많이 인식하므로 상승 부분을 하강 부분보다 짧게 만들면 하강 부분에 더 중점을 둘 수 있습니다.
다음은 원을 펼치고 접는 이 컴포지션의 적용 예입니다. 상승 효과는 애니메이션 중에 확장되는 느낌을 강화할 수 있습니다. 상승 및 하강 효과를 조합하면 애니메이션 끝부분의 축소를 강조하는 데 도움이 됩니다.
그림 2. 이 파형은 기기에서 진동의 출력 가속을 나타냅니다.
enum class ExpandShapeState { Collapsed, Expanded } @Composable fun ExpandScreen() { // Control variable for the state of the indicator. var currentState by remember { mutableStateOf(ExpandShapeState.Collapsed) } // Animation between expanded and collapsed states. val transitionData = updateTransitionData(currentState) Column( Modifier .clickable( onClick = { if (currentState == ExpandShapeState.Collapsed) { currentState = ExpandShapeState.Expanded vibrator.vibrate( VibrationEffect.startComposition().addPrimitive( VibrationEffect.Composition.PRIMITIVE_SLOW_RISE, 0.3f ).addPrimitive( VibrationEffect.Composition.PRIMITIVE_QUICK_FALL, 0.3f ).compose() ) } else { currentState = ExpandShapeState.Collapsed vibrator.vibrate( VibrationEffect.startComposition().addPrimitive( VibrationEffect.Composition.PRIMITIVE_SLOW_RISE ).compose() ) } } ) ) { // Build the indicator UI based on the current state. ExpandIndicator(transitionData) } }
흔들림 (회전 포함)
핵심 햅틱 원칙 중 하나는 사용자에게 즐거움을 주는 것입니다. 기분 좋고 예상치 못한 진동 효과를 도입하는 재미있는 방법은 PRIMITIVE_SPIN를 사용하는 것입니다. 이 프리미티브는 두 번 이상 호출될 때 가장 효과적입니다. 여러 회전을 연결하면 흔들리고 불안정한 효과가 발생할 수 있으며, 각 프리미티브에 다소 무작위적인 배율을 적용하여 이를 더욱 향상시킬 수 있습니다. 연속된 회전 프리미티브 사이의 간격을 실험해 볼 수도 있습니다. 간격 없이 (0ms) 두 번 회전하면 꽉 조이는 회전 감각이 생성됩니다. 회전 간격을 10ms에서 50ms로 늘리면 회전 느낌이 느슨해지며 동영상이나 애니메이션의 길이를 일치시키는 데 사용할 수 있습니다.
100ms보다 긴 간격을 사용하지 마세요. 연속 회전이 더 이상 잘 통합되지 않고 개별 효과처럼 느껴지기 시작합니다.
다음은 아래로 드래그한 후 놓으면 다시 튀어 오르는 탄력적인 도형의 예입니다. 애니메이션은 바운스 변위에 비례하는 다양한 강도로 재생되는 한 쌍의 회전 효과로 향상됩니다.
그림 3. 이 파형은 기기에서 진동의 출력 가속도를 나타냅니다.
@Composable fun WobbleScreen() { // Control variables for the dragging and animating state of the elastic. var dragDistance by remember { mutableFloatStateOf(0f) } var isWobbling by remember { mutableStateOf(false) } // Use drag distance to create an animated float value behaving like a spring. val dragDistanceAnimated by animateFloatAsState( targetValue = if (dragDistance > 0f) dragDistance else 0f, animationSpec = spring( dampingRatio = Spring.DampingRatioHighBouncy, stiffness = Spring.StiffnessMedium ), // The spring ends on its target. An end value of 0f means that the // shape is back at rest, so the wobble is over. finishedListener = { endValue -> if (endValue == 0f) isWobbling = false }, ) LaunchedEffect(isWobbling) { while (isWobbling) { val displacement = dragDistanceAnimated / MAX_DRAG_DISTANCE // Use some sort of minimum displacement so the final few frames // of animation don't generate a vibration. if (displacement > SPIN_MIN_DISPLACEMENT) { vibrator.vibrate( VibrationEffect.startComposition().addPrimitive( VibrationEffect.Composition.PRIMITIVE_SPIN, nextSpinScale(displacement) ).addPrimitive( VibrationEffect.Composition.PRIMITIVE_SPIN, nextSpinScale(displacement) ).compose() ) } // Delay the next check for a sufficient duration until the // current composition finishes. Note that you can use // Vibrator.getPrimitiveDurations API to calculcate the delay. delay(VIBRATION_DURATION) } } Box( Modifier .fillMaxSize() .draggable( onDragStopped = { // Wobble only if the shape is far enough from rest to vibrate. // It then springs back to 0f, and the finishedListener ends // the wobble. val displacement = dragDistanceAnimated / MAX_DRAG_DISTANCE isWobbling = displacement > SPIN_MIN_DISPLACEMENT dragDistance = 0f }, orientation = Orientation.Vertical, state = rememberDraggableState { delta -> isWobbling = false dragDistance += delta } ) ) { // Draw the wobbling shape using the animated spring-like value. The // lambda defers the read to the draw phase, so the animation doesn't // recompose this screen on every frame. WobbleShape { dragDistanceAnimated } } } // Calculate a random scale for each spin to vary the full effect. fun nextSpinScale(displacement: Float): Float { // Generate a random offset in the range [-0.1, +0.1] to be added to the // vibration scale so the spin effects have slightly different values. val randomOffset: Float = Random.Default.nextFloat() * 0.2f - 0.1f return (displacement + randomOffset).absoluteValue.coerceIn(0f, 1f) }
바운스 (쿵 소리 포함)
진동 효과의 또 다른 고급 적용은 물리적 상호작용을 시뮬레이션하는 것입니다. PRIMITIVE_THUD는 강력하고 울림이 있는 효과를 만들 수 있으며, 동영상이나 애니메이션에서 충격의 시각화와 결합하여 전반적인 경험을 보강할 수 있습니다.
다음은 공이 화면 하단에서 튕길 때마다 쿵 소리 효과가 재생되는 공 떨어뜨리기 애니메이션의 예입니다.
그림 4. 이 파형은 기기에서 진동의 출력 가속도를 나타냅니다.
enum class BallPosition { Start, End } @Composable fun BounceScreen() { // Control variable for the state of the ball. var ballPosition by remember { mutableStateOf(BallPosition.Start) } // Plain holder for the bounce count. It's read only in the effect and in // the click handler, never in composition, so it needs no snapshot state. val bounceCount = remember { intArrayOf(0) } // Animation for the bouncing ball. val transitionData = updateTransitionData(ballPosition) val collisionData = updateCollisionData(transitionData) // Ball is about to contact floor. The effect runs only when the collision // state changes, so it vibrates once per collision. Nothing here suspends, // so a keyed SideEffect does the job without launching a coroutine. SideEffect(collisionData.collisionWithFloor) { if (collisionData.collisionWithFloor) { val vibrationScale = 0.7.pow(bounceCount[0]++).toFloat() vibrator.vibrate( VibrationEffect.startComposition().addPrimitive( VibrationEffect.Composition.PRIMITIVE_THUD, vibrationScale ).compose() ) } } Box( Modifier .fillMaxSize() .clickable { if (transitionData.isAtStart) { ballPosition = BallPosition.End } else { ballPosition = BallPosition.Start bounceCount[0] = 0 } }, ) { // Build the ball UI based on the current state. BouncingBall(transitionData) } }
엔벨로프가 있는 진동 파형
맞춤 진동 패턴을 만드는 프로세스를 사용하면 진동 진폭을 제어하여 부드러운 램프 업 및 램프 다운 효과를 만들 수 있습니다. 이 섹션에서는 시간에 따른 진동 진폭과 주파수를 정밀하게 제어할 수 있는 파형 엔벨로프를 사용하여 동적 햅틱 효과를 만드는 방법을 설명합니다. 이를 통해 더 풍부하고 미묘한 햅틱 환경을 만들 수 있습니다.
Android 16 (API 수준 36)부터 시스템은 일련의 제어점을 정의하여 진동 파형 엔벨로프를 만드는 다음 API를 제공합니다.
BasicEnvelopeBuilder: 하드웨어 제약이 없는 햅틱 효과를 만드는 접근성 높은 접근 방식입니다.WaveformEnvelopeBuilder: 햅틱 효과를 만드는 고급 접근 방식으로, 햅틱 하드웨어에 익숙해야 합니다.
Android는 엔벨로프 효과의 대체 기능을 제공하지 않습니다. 이 지원이 필요한 경우 다음 단계를 완료하세요.
Vibrator.areEnvelopeEffectsSupported()를 사용하여 특정 기기가 엔벨로프 효과를 지원하는지 확인합니다.- 지원되지 않는 일관된 환경 세트를 사용 중지하거나 맞춤 진동 패턴 또는 컴포지션을 대체 대안으로 사용합니다.
더 기본적인 엔벨로프 효과를 만들려면 다음 매개변수와 함께 BasicEnvelopeBuilder를 사용하세요.
- \( [0, 1] \)범위의 강도 값으로, 진동의 인지된 강도를 나타냅니다. 예를 들어 \( 0.5 \)값은 기기에서 달성할 수 있는 전역 최대 강도의 절반으로 인식됩니다.
\( [0, 1] \)범위의 선명도 값으로, 진동의 선명도를 나타냅니다. 값이 낮을수록 진동이 부드러워지고 값이 높을수록 더 날카로운 느낌이 생성됩니다.
duration 값: 마지막 제어점(강도 및 선명도 쌍)에서 새 제어점으로 전환하는 데 걸리는 시간(밀리초)을 나타냅니다.
다음은 500ms에 걸쳐 저음에서 고음의 최대 강도 진동으로 강도를 높인 다음 100ms에 걸쳐 다시\( 0 \) (꺼짐)으로 낮추는 파형의 예입니다.
vibrator.vibrate( VibrationEffect.BasicEnvelopeBuilder() .setInitialSharpness(0.0f) .addControlPoint(1.0f, 1.0f, 500) .addControlPoint(0.0f, 1.0f, 100) .build() )
햅틱에 관한 고급 지식이 있는 경우 WaveformEnvelopeBuilder를 사용하여 엔벨로프 효과를 정의할 수 있습니다. 이 객체를 사용할 때 VibratorFrequencyProfile를 통해 주파수-출력 가속 매핑 (FOAM)에 액세스할 수 있습니다.
- \( [0, 1] \)범위의 진폭 값으로, 기기 FOAM에 의해 결정된 특정 주파수에서 달성 가능한 진동 강도를 나타냅니다. 예를 들어 \( 0.5 \) 값은 지정된 주파수에서 달성할 수 있는 최대 출력 가속의 절반을 생성합니다.
주파수 값(Hz)입니다.
duration 값: 마지막 제어 지점에서 새 제어 지점으로 전환하는 데 걸리는 시간(밀리초)을 나타냅니다.
다음 코드는 400ms 진동 효과를 정의하는 파형의 예를 보여줍니다. 60Hz의 일정한 주파수에서 50ms 진폭 램프(꺼짐에서 최대까지)로 시작합니다. 그런 다음 주파수가 다음 100ms에 걸쳐 120Hz까지 증가하고 200ms 동안 해당 수준으로 유지됩니다. 마지막으로 진폭이 \( 0 \)까지 감소하고 주파수는 마지막 50ms에 걸쳐 60Hz로 돌아갑니다.
vibrator.vibrate( VibrationEffect.WaveformEnvelopeBuilder() .addControlPoint(1.0f, 60f, 50) .addControlPoint(1.0f, 120f, 100) .addControlPoint(1.0f, 120f, 200) .addControlPoint(0.0f, 60f, 50) .build() )
다음 섹션에서는 엔벨로프가 있는 진동 파형의 몇 가지 예를 제공합니다.
통통 튀는 스프링
이전 샘플에서는 PRIMITIVE_THUD를 사용하여 물리적 바운스 상호작용을 시뮬레이션합니다. 기본 엔벨로프 API는 훨씬 더 세밀한 제어 기능을 제공하므로 진동 강도와 선명도를 정밀하게 조정할 수 있습니다.
이렇게 하면 애니메이션 이벤트에 더 정확하게 반응하는 햅틱 반응이 생성됩니다.
다음은 스프링이 화면 하단에서 튕길 때마다 재생되는 기본 엔벨로프 효과로 애니메이션이 향상된 자유 낙하 스프링의 예입니다.
그림 5. 튀는 스프링을 시뮬레이션하는 진동의 출력 가속 파형 그래프
@Composable fun BouncingSpringAnimation() { // The spring's height above the floor: 1f is the drop height, 0f is the floor. val springHeight = remember { Animatable(1f) } var isAnimating by remember { mutableStateOf(true) } LaunchedEffect(isAnimating) { if (!isAnimating) return@LaunchedEffect springHeight.snapTo(1f) // Each bounce reaches a fraction of the previous height. var bounceHeight = 1f while (bounceHeight > MIN_BOUNCE_HEIGHT) { // Fall to the floor, speeding up like a falling object. A fall from // a greater height takes longer. val fallDurationMs = (DROP_DURATION_MS * sqrt(bounceHeight)).toInt() springHeight.animateTo(0f, tween(fallDurationMs, easing = EaseInQuad)) // The spring hits the floor: play a "boing" envelope effect that fades // out. A higher bounce gives a stronger and sharper vibration. vibrator.vibrate( VibrationEffect.BasicEnvelopeBuilder() // Starting from zero sharpness here, will simulate a smoother // "boing" effect. .setInitialSharpness(0f) // Add a control point to reach the target intensity and // sharpness very quickly. .addControlPoint(bounceHeight, bounceHeight * MAX_SHARPNESS, 20L) // Add a control point to fade out the vibration intensity while // maintaining sharpness. .addControlPoint(0f, bounceHeight * MAX_SHARPNESS, fallDurationMs.toLong()) .build() ) // Bounce back up to a lower height, slowing down like a thrown object. // When the next bounce is too small, the spring stays on the floor. bounceHeight *= BOUNCE_DAMPING if (bounceHeight > MIN_BOUNCE_HEIGHT) { val riseDurationMs = (DROP_DURATION_MS * sqrt(bounceHeight)).toInt() springHeight.animateTo(bounceHeight, tween(riseDurationMs, easing = EaseOutQuad)) } } isAnimating = false } Box( modifier = Modifier .fillMaxSize() .clickable(enabled = !isAnimating) { isAnimating = true } .drawBehind { // Read the height in the draw phase, and scale it to the distance // between the top of the box and the floor. val floorTop = size.height - FLOOR_HEIGHT.toPx() val dropDistance = floorTop - SPRING_HEIGHT.toPx() drawSpring(topY = dropDistance * (1f - springHeight.value)) drawFloor(top = floorTop) } ) { if (!isAnimating) { Text("Tap to restart", Modifier.align(Alignment.Center)) } } }
로켓 발사
이전 샘플에서는 기본 엔벨로프 API를 사용하여 탄력 있는 스프링 반응을 시뮬레이션하는 방법을 보여줍니다. WaveformEnvelopeBuilder는 기기의 전체 주파수 범위를 정밀하게 제어하여 고도로 맞춤설정된 햅틱 효과를 지원합니다. 이를 FOAM 데이터와 결합하면 특정 주파수 기능에 맞게 진동을 조정할 수 있습니다.
다음은 동적 진동 패턴을 사용하는 로켓 발사 시뮬레이션을 보여주는 예입니다. 효과는 지원되는 최소 주파수 가속 출력인 0.1G에서 공진 주파수로 이동하며 항상 10% 진폭 입력을 유지합니다. 이렇게 하면 구동 진폭이 동일하더라도 효과가 적절히 강한 출력으로 시작하고 인지되는 강도와 선명도가 증가합니다. 공명에 도달하면 효과 주파수가 다시 최소값으로 내려가며, 이는 강도와 선명도가 감소하는 것으로 인식됩니다. 이로 인해 초기 저항감이 느껴진 후 해제되어 우주로 발사되는 느낌을 모방합니다.
이 효과는 기본 엔벨로프 API로는 불가능합니다. 공진 주파수 및 출력 가속 곡선에 관한 기기별 정보를 추상화하기 때문입니다. 선명도를 높이면 등가 주파수가 공진을 넘어설 수 있으며, 이로 인해 의도하지 않은 가속 저하가 발생할 수 있습니다.
그림 6. 로켓 발사를 시뮬레이션하는 진동의 출력 가속 파형 그래프
@Composable fun RocketLaunchAnimation() { var isLaunched by remember { mutableStateOf(false) } val animation = remember { Animatable(0f) } val animationDuration = 3000 LaunchedEffect(isLaunched) { if (isLaunched) { animation.animateTo( 1.2f, // Overshoot so that the rocket goes off the screen. animationSpec = tween( durationMillis = animationDuration, // Applies an easing curve with a slow start and rapid acceleration // towards the end. easing = CubicBezierEasing(1f, 0f, 0.75f, 1f) ) ) animation.snapTo(0f) isLaunched = false } } Canvas( modifier = Modifier .fillMaxSize() .clickable { if (!isLaunched) { // Play vibration with same duration as the animation, using 70% of // the time for the rise of the vibration, to match the easing curve // defined previously. playVibration(vibrator, animationDuration.toLong(), 0.7f) isLaunched = true } } .background(colorResource(R.color.background)) ) { // Read the animation in the draw phase, and scale it to the canvas height. drawRocket(size.height * animation.value) } } private fun playVibration( vibrator: Vibrator, totalDurationMs: Long, riseBias: Float, minOutputAccelerationGs: Float = 0.1f, ) { require(riseBias in 0f..1f) { "Rise bias must be between 0 and 1." } if (!vibrator.areEnvelopeEffectsSupported()) { return } val resonantFrequency = vibrator.resonantFrequency if (resonantFrequency.isNaN()) { // Device doesn't have or expose a resonant frequency. return } val startFrequency = vibrator.frequencyProfile?.getFrequencyRange(minOutputAccelerationGs)?.lower ?: return if (startFrequency >= resonantFrequency) { // Vibrator can't generate the minimum required output at lower frequencies. return } val minDurationMs = vibrator.envelopeEffectInfo.minControlPointDurationMillis val rampUpDurationMs = (riseBias * totalDurationMs).toLong() - minDurationMs val rampDownDurationMs = totalDurationMs - rampUpDurationMs - minDurationMs vibrator.vibrate( VibrationEffect.WaveformEnvelopeBuilder() // Quickly reach the target output at the start frequency .addControlPoint(0.1f, startFrequency, minDurationMs) .addControlPoint(0.1f, resonantFrequency, rampUpDurationMs) .addControlPoint(0.1f, startFrequency, rampDownDurationMs) // Controlled ramp down to zero to avoid ringing after the vibration. .addControlPoint(0.0f, startFrequency, minDurationMs) .build() ) }
LavaBeats
Rocket Launch 예시와 같이 WaveformEnvelopeBuilder API를 사용하면 진동의 진폭 및 주파수 세그먼트를 제어하여 여러 복잡한 햅틱 효과를 설계할 수 있습니다. 이러한 디자인의 또 다른 예는 '생동감'과 같은 더 추상적인 신체 감각을 에뮬레이션하는 것입니다.
이는 특정 진폭과 주파수의 진동 세그먼트로 일반적인 심전도 (ECG) 신호의 바이오마커를 표현하여 구현할 수 있습니다. LavaBeats는 ECG 기록의 두 가지 특성 세그먼트가 시간 지연으로 구분된 두 개의 펄스로 표시되는 예입니다. 첫 번째 특징적인 맥박은 QRS 복합체로, 진폭이 크고 지속 시간이 짧은 날카로운 피크로 나타납니다. 두 번째 펄스는 T파로, 진폭이 더 낮고 지속 시간이 더 길며 모양이 더 부드럽습니다 (그림 7 참고).
WaveformEnvelopeBuilder를 사용하여 고정된 첫 번째-두 번째 펄스 지연으로 구분된 이 두 펄스의 다양한 반복을 구성합니다. 첫 번째 펄스는 짧은 시간 동안 낮은 주파수에서 시작하여 더 높은 주파수에서 끝나는 처프 신호일 수 있습니다. 두 번째 펄스는 저주파 사인파의 단일 주기로 표현될 수 있습니다. 두 펄스를 비트로 구성하고 일반적인 분당 비트 수 (bpm)에 따라 지연을 두고 구성을 여러 번 반복할 수 있습니다. 그 결과 심장 박동과 유사한 햅틱 효과가 발생합니다.
GitHub의 햅틱 샘플 앱에서 LavaBeats를 사용해 보고 햅틱 효과와 동일한 리듬으로 박동하는 라바 램프 시각화와 함께 효과를 느껴볼 수 있습니다. 효과의 설정을 변경하여 두 펄스의 진폭, 주파수, 지속 시간, 지연을 수정하여 다양한 비트 감각을 만들 수도 있습니다.
그림 7. QRS 복합 및 T파가 있는 ECG 기록의 세그먼트
private fun createEnvelopeEffect( beatParameters: List<BeatParameter> ): 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() /** A parameter of a haptic beat effect that represents an ECG signal parameter */ data class BeatParameter( val description: String = "", val value: Float = 0f, val range: ClosedFloatingPointRange<Float> = 0f..1f, val steps: Int = 0, val isFrequencyType: Boolean = false, )