Tạo hiệu ứng xúc giác tuỳ chỉnh

Trang này trình bày các ví dụ về cách sử dụng nhiều API haptics để tạo hiệu ứng tuỳ chỉnh ngoài đường dạng sóng rung chuẩn trong ứng dụng Android.

Trang này bao gồm các ví dụ sau:

Để biết thêm ví dụ, hãy xem phần Thêm phản hồi xúc giác vào sự kiện và luôn tuân thủ nguyên tắc thiết kế xúc giác.

Sử dụng phương án dự phòng để xử lý khả năng tương thích với thiết bị

Khi triển khai hiệu ứng tuỳ chỉnh, hãy cân nhắc những điều sau:

  • Khả năng cần có của thiết bị để có hiệu ứng
  • Việc cần làm khi thiết bị không phát được hiệu ứng

Tài liệu tham khảo API haptics của Android cung cấp thông tin chi tiết về cách kiểm tra tính năng hỗ trợ cho các thành phần liên quan đến haptics để ứng dụng của bạn có thể mang lại trải nghiệm tổng thể nhất quán.

Tuỳ thuộc vào trường hợp sử dụng, bạn có thể muốn tắt hiệu ứng tuỳ chỉnh hoặc cung cấp hiệu ứng tuỳ chỉnh thay thế dựa trên nhiều khả năng tiềm năng.

Lập kế hoạch cho các lớp cấp cao sau đây về khả năng của thiết bị:

  • Nếu bạn đang sử dụng các thành phần gốc của phản hồi xúc giác: thiết bị hỗ trợ các thành phần gốc đó mà hiệu ứng tuỳ chỉnh cần đến. (Xem phần tiếp theo để biết thông tin chi tiết về các đối tượng gốc.)

  • Thiết bị có chế độ điều khiển biên độ.

  • Thiết bị có tính năng hỗ trợ rung cơ bản (bật/tắt) – nói cách khác, những thiết bị thiếu tính năng điều khiển biên độ.

Nếu lựa chọn hiệu ứng xúc giác của ứng dụng của bạn tính đến các danh mục này, thì trải nghiệm xúc giác của người dùng sẽ vẫn có thể dự đoán được trên mọi thiết bị.

Sử dụng các nguyên hàm xúc giác

Android bao gồm một số nguyên hàm haptics khác nhau về cả biên độ và tần số. Bạn có thể sử dụng một đối tượng gốc hoặc kết hợp nhiều đối tượng gốc để đạt được hiệu ứng xúc giác phong phú.

  • Sử dụng độ trễ từ 50 mili giây trở lên cho các khoảng trống có thể nhận biết được giữa hai đối tượng gốc, đồng thời tính đến thời lượng đối tượng gốc nếu có thể.
  • Sử dụng các thang đo có tỷ lệ chênh lệch từ 1,4 trở lên để người dùng dễ nhận biết sự khác biệt về cường độ.
  • Sử dụng tỷ lệ 0,5, 0,7 và 1,0 để tạo phiên bản cường độ thấp, trung bình và cao của một đối tượng gốc.

Tạo kiểu rung tuỳ chỉnh

Các mẫu rung thường được dùng trong phản hồi xúc giác thu hút sự chú ý, chẳng hạn như thông báo và nhạc chuông. Dịch vụ Vibrator có thể phát các mẫu rung dài thay đổi biên độ rung theo thời gian. Những hiệu ứng như vậy được gọi là sóng.

Hiệu ứng dạng sóng thường dễ nhận biết, nhưng độ rung dài đột ngột có thể khiến người dùng giật mình nếu phát trong môi trường yên tĩnh. Việc tăng cường độ mục tiêu quá nhanh cũng có thể tạo ra tiếng ù ù. Thiết kế các mẫu dạng sóng để làm mượt các chuyển đổi biên độ nhằm tạo hiệu ứng tăng và giảm.

Ví dụ về kiểu rung

Các phần sau đây cung cấp một số ví dụ về mẫu rung:

Mẫu tăng dần

Các dạng sóng được biểu thị dưới dạng VibrationEffect với ba tham số:

  1. Timings (Thời gian): một mảng thời lượng, tính bằng mili giây, cho mỗi phân đoạn sóng hình.
  2. Độ biên độ: độ biên độ rung mong muốn cho mỗi thời lượng được chỉ định trong đối số đầu tiên, được biểu thị bằng giá trị số nguyên từ 0 đến 255, trong đó 0 biểu thị "trạng thái tắt" của bộ rung và 255 là độ biên độ tối đa của thiết bị.
  3. Chỉ mục lặp lại: chỉ mục trong mảng được chỉ định trong đối số đầu tiên để bắt đầu lặp lại dạng sóng hoặc -1 nếu chỉ phát mẫu một lần.

Dưới đây là một ví dụ về dạng sóng xung nhịp hai lần với thời gian tạm dừng là 350 mili giây giữa các xung nhịp. Xung đầu tiên là một dải tăng dần mượt mà đến biên độ tối đa và xung thứ hai là một dải tăng nhanh để giữ biên độ tối đa. Việc dừng ở cuối được xác định bằng giá trị chỉ mục lặp lại âm.

Kotlin

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))

Java

long[] timings = new long[] {
    50, 50, 50, 50, 50, 100, 350, 25, 25, 25, 25, 200 };
int[] amplitudes = new int[] {
    33, 51, 75, 113, 170, 255, 0, 38, 62, 100, 160, 255 };
int repeatIndex = -1; // Don't repeat.

vibrator.vibrate(VibrationEffect.createWaveform(
    timings, amplitudes, repeatIndex));

Mẫu lặp lại

Bạn cũng có thể phát sóng hình dạng sóng nhiều lần cho đến khi huỷ. Cách tạo sóng hình lặp lại là đặt tham số repeat không âm. Khi bạn phát một sóng hình lặp lại, độ rung sẽ tiếp tục cho đến khi được huỷ rõ ràng trong dịch vụ:

Kotlin

void 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.
VibrationEffect repeatingEffect = VibrationEffect.createWaveform(
    timings, amplitudes, repeat)
// repeatingEffect can be used in multiple places.

vibrator.vibrate(repeatingEffect)
}

void stopVibrating() {
vibrator.cancel()
}

Java

void startVibrating() {
long[] timings = new long[] { 50, 50, 100, 50, 50 };
int[] amplitudes = new int[] { 64, 128, 255, 128, 64 };
int repeat = 1; // Repeat from the second entry, index = 1.
VibrationEffect repeatingEffect = VibrationEffect.createWaveform(
    timings, amplitudes, repeat);
// repeatingEffect can be used in multiple places.

vibrator.vibrate(repeatingEffect);
}

void stopVibrating() {
vibrator.cancel();
}

Điều này rất hữu ích đối với các sự kiện không liên tục cần có hành động của người dùng để xác nhận. Ví dụ về các sự kiện như vậy bao gồm cuộc gọi điện thoại đến và chuông báo được kích hoạt.

Mẫu có dự phòng

Việc kiểm soát biên độ của một tín hiệu rung là một chức năng phụ thuộc vào phần cứng. Việc phát một dạng sóng trên thiết bị cấp thấp mà không có chức năng này sẽ khiến thiết bị rung ở biên độ tối đa cho mỗi mục nhập dương trong mảng biên độ. Nếu ứng dụng của bạn cần điều chỉnh cho phù hợp với các thiết bị như vậy, hãy sử dụng một mẫu không tạo ra hiệu ứng rung khi phát trong điều kiện đó hoặc thiết kế một mẫu BẬT/TẮT đơn giản hơn có thể phát làm dự phòng.

Kotlin

if (vibrator.hasAmplitudeControl()) {
  vibrator.vibrate(VibrationEffect.createWaveform(
    smoothTimings, amplitudes, smoothRepeatIdx))
} else {
  vibrator.vibrate(VibrationEffect.createWaveform(
    onOffTimings, onOffRepeatIdx))
}

Java

if (vibrator.hasAmplitudeControl()) {
  vibrator.vibrate(VibrationEffect.createWaveform(
    smoothTimings, amplitudes, smoothRepeatIdx));
} else {
  vibrator.vibrate(VibrationEffect.createWaveform(
    onOffTimings, onOffRepeatIdx));
}

Tạo thành phần rung

Phần này trình bày các cách kết hợp độ rung thành các hiệu ứng tuỳ chỉnh dài hơn và phức tạp hơn, đồng thời khám phá các tính năng cảm ứng đa dạng bằng cách sử dụng các tính năng phần cứng nâng cao hơn. Bạn có thể sử dụng các tổ hợp hiệu ứng thay đổi biên độ và tần số để tạo hiệu ứng xúc giác phức tạp hơn trên các thiết bị có bộ truyền động xúc giác có băng thông tần số rộng hơn.

Quá trình tạo kiểu rung tuỳ chỉnh, được mô tả trước đó trên trang này, giải thích cách kiểm soát biên độ rung để tạo hiệu ứng tăng và giảm mượt mà. Công nghệ haptics phong phú cải thiện khái niệm này bằng cách khám phá phạm vi tần số rộng hơn của bộ rung thiết bị để làm cho hiệu ứng trở nên mượt mà hơn. Các dạng sóng này đặc biệt hiệu quả trong việc tạo hiệu ứng crescendo hoặc diminuendo.

Thành phần gốc của thành phần Compose, được mô tả ở trên trang này, do nhà sản xuất thiết bị triển khai. Chúng mang lại cảm giác rung rõ ràng, ngắn và dễ chịu, phù hợp với nguyên tắc haptics để mang lại trải nghiệm haptics rõ ràng. Để biết thêm thông tin chi tiết về các chức năng này và cách hoạt động, hãy xem bài viết Giới thiệu về bộ truyền động rung.

Android không cung cấp phương án dự phòng cho các thành phần có các thành phần gốc không được hỗ trợ. Do đó, hãy thực hiện các bước sau:

  1. Trước khi kích hoạt tính năng haptics nâng cao, hãy kiểm tra để đảm bảo rằng một thiết bị nhất định hỗ trợ tất cả các đối tượng gốc mà bạn đang sử dụng.

  2. Tắt nhóm trải nghiệm nhất quán không được hỗ trợ, chứ không chỉ các hiệu ứng bị thiếu một thành phần gốc.

Thông tin khác về cách kiểm tra khả năng hỗ trợ của thiết bị sẽ được trình bày trong các phần sau.

Tạo hiệu ứng rung kết hợp

Bạn có thể tạo hiệu ứng rung được kết hợp bằng VibrationEffect.Composition. Dưới đây là ví dụ về hiệu ứng tăng dần chậm, theo sau là hiệu ứng nhấp mạnh:

Kotlin

vibrator.vibrate(
    VibrationEffect.startComposition().addPrimitive(
    VibrationEffect.Composition.PRIMITIVE_SLOW_RISE
    ).addPrimitive(
    VibrationEffect.Composition.PRIMITIVE_CLICK
    ).compose()
)

Java

vibrator.vibrate(
    VibrationEffect.startComposition()
        .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SLOW_RISE)
        .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK)
        .compose());

Bạn có thể tạo một thành phần bằng cách thêm các thành phần gốc để phát theo trình tự. Mỗi đối tượng gốc cũng có thể mở rộng, vì vậy, bạn có thể kiểm soát biên độ của độ rung do mỗi đối tượng gốc tạo ra. Tỷ lệ được xác định là một giá trị từ 0 đến 1, trong đó 0 thực sự liên kết với biên độ tối thiểu mà người dùng có thể cảm nhận được (gần như không).

Tạo biến thể trong các nguyên hàm rung

Nếu bạn muốn tạo một phiên bản yếu và mạnh của cùng một đối tượng gốc, hãy tạo tỷ lệ cường độ từ 1, 4 trở lên để có thể dễ dàng nhận thấy sự khác biệt về cường độ. Đừng cố tạo nhiều hơn 3 cấp độ cường độ của cùng một nguyên hàm, vì chúng không khác biệt về mặt cảm nhận. Ví dụ: sử dụng tỷ lệ 0, 5, 0, 7 và 1, 0 để tạo các phiên bản cường độ thấp, trung bình và cao của một đối tượng gốc.

Thêm khoảng trống giữa các nguyên hàm rung

Cấu trúc cũng có thể chỉ định độ trễ cần thêm vào giữa các thành phần gốc liên tiếp. Độ trễ này được biểu thị bằng mili giây kể từ khi kết thúc nguyên hàm trước đó. Nhìn chung, khoảng cách 5 đến 10 mili giây giữa hai đối tượng gốc là quá ngắn để phát hiện được. Sử dụng khoảng cách khoảng 50 mili giây trở lên nếu bạn muốn tạo khoảng cách rõ ràng giữa hai đối tượng gốc. Dưới đây là ví dụ về một thành phần có độ trễ:

Kotlin

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()
)

Java

int 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());

Kiểm tra xem các loại dữ liệu gốc nào được hỗ trợ

Bạn có thể sử dụng các API sau để xác minh khả năng hỗ trợ thiết bị cho các đối tượng gốc cụ thể:

Kotlin

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.
}

Java

int 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.
}

Bạn cũng có thể kiểm tra nhiều đối tượng gốc, sau đó quyết định đối tượng gốc nào sẽ được kết hợp dựa trên cấp độ hỗ trợ thiết bị:

Kotlin

val effects: IntArray = intArrayOf(
VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
VibrationEffect.Composition.PRIMITIVE_TICK,
VibrationEffect.Composition.PRIMITIVE_CLICK
)
val supported: BooleanArray = vibrator.arePrimitivesSupported(primitives)

Java

int[] primitives = new int[] {
VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
VibrationEffect.Composition.PRIMITIVE_TICK,
VibrationEffect.Composition.PRIMITIVE_CLICK
};
boolean[] supported = vibrator.arePrimitivesSupported(effects);

Ví dụ về thành phần rung

Các phần sau đây cung cấp một số ví dụ về thành phần rung, được lấy từ ứng dụng mẫu haptics trên GitHub.

Kháng cự (với số lần đánh dấu thấp)

Bạn có thể kiểm soát biên độ của chế độ rung nguyên gốc để truyền tải phản hồi hữu ích cho một hành động đang diễn ra. Bạn có thể sử dụng các giá trị tỷ lệ được đặt gần nhau để tạo hiệu ứng crescendo mượt mà của một đối tượng gốc. Độ trễ giữa các mẫu gốc liên tiếp cũng có thể được đặt linh động dựa trên hoạt động tương tác của người dùng. Điều này được minh hoạ trong ví dụ sau về ảnh động trong khung hiển thị được điều khiển bằng cử chỉ kéo và được tăng cường bằng tính năng phản hồi xúc giác.

Ảnh động về một vòng tròn đang được kéo xuống.
Biểu đồ dạng sóng rung đầu vào.

Hình 1. Hình dạng sóng này thể hiện gia tốc đầu ra của độ rung trên một thiết bị.

Kotlin

@Composable
fun ResistScreen() {
    // Control variables for the dragging of the indicator.
    var isDragging by remember { mutableStateOf(false) }
    var dragOffset by remember { mutableStateOf(0f) }

    // Only vibrates while the user is dragging
    if (isDragging) {
        LaunchedEffect(Unit) {
        // Continuously run the effect for vibration to occur even when the view
        // is not being drawn, when user stops dragging midway through gesture.
        while (true) {
            // 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()
            )
        }
        }
    }

    Screen() {
        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)
        }
    }
}

Java

class DragListener implements View.OnTouchListener {
    // Control variables for the dragging of the indicator.
    private int startY;
    private int vibrationInterval;
    private float vibrationScale;

    @Override
    public boolean onTouch(View view, MotionEvent event) {
        switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            startY = event.getRawY();
            vibrationInterval = calculateVibrationInterval(0);
            vibrationScale = calculateVibrationScale(0);
            startVibration();
            break;
        case MotionEvent.ACTION_MOVE:
            float dragOffset = event.getRawY() - startY;
            // Calculate the interval inversely proportional to the drag offset.
            vibrationInterval = calculateVibrationInterval(dragOffset);
            // Calculate the scale directly proportional to the drag offset.
            vibrationScale = calculateVibrationScale(dragOffset);
            // Build the indicator UI based on how much the user has dragged it.
            updateIndicator(dragOffset);
            break;
        case MotionEvent.ACTION_CANCEL:
        case MotionEvent.ACTION_UP:
            // Only vibrates while the user is dragging
            cancelVibration();
            break;
        }
        return true;
    }

    private void startVibration() {
        vibrator.vibrate(
            VibrationEffect.startComposition()
                .addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
                        vibrationScale)
                .compose());

        // Continuously run the effect for vibration to occur even when the view
        // is not being drawn, when user stops dragging midway through gesture.
        handler.postDelayed(this::startVibration, vibrationInterval);
    }

    private void cancelVibration() {
        handler.removeCallbacksAndMessages(null);
    }
}

Mở rộng (với sự tăng và giảm)

Có hai hàm gốc để tăng cường độ rung được cảm nhận: PRIMITIVE_QUICK_RISEPRIMITIVE_SLOW_RISE. Cả hai đều đạt được cùng một mục tiêu, nhưng có thời lượng khác nhau. Chỉ có một hàm gốc để giảm dần, PRIMITIVE_QUICK_FALL. Các đối tượng gốc này hoạt động hiệu quả hơn cùng nhau để tạo ra một đoạn sóng tăng cường độ rồi giảm dần. Bạn có thể căn chỉnh các đối tượng gốc theo tỷ lệ để ngăn chặn sự tăng đột biến về biên độ giữa các đối tượng gốc. Điều này cũng hiệu quả trong việc kéo dài thời lượng hiệu ứng tổng thể. Về mặt cảm nhận, mọi người luôn chú ý đến phần tăng nhiều hơn phần giảm, vì vậy, bạn có thể sử dụng phần tăng ngắn hơn phần giảm để chuyển trọng tâm sang phần giảm.

Dưới đây là ví dụ về cách áp dụng thành phần này để mở rộng và thu gọn một vòng tròn. Hiệu ứng tăng có thể tăng cường cảm giác mở rộng trong ảnh động. Việc kết hợp hiệu ứng tăng và giảm giúp làm nổi bật thao tác thu gọn ở cuối ảnh động.

Ảnh động về một vòng tròn đang mở rộng.
Biểu đồ dạng sóng rung đầu vào.

Hình 2.Đường dạng sóng này thể hiện gia tốc đầu ra của độ rung trên một thiết bị.

Kotlin

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)

    Screen() {
        Column(
        Modifier
            .clickable(
            {
                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)
        }
    }
}

Java

class ClickListener implements View.OnClickListener {
    private final Animation expandAnimation;
    private final Animation collapseAnimation;
    private boolean isExpanded;

    ClickListener(Context context) {
        expandAnimation = AnimationUtils.loadAnimation(context, R.anim.expand);
        expandAnimation.setAnimationListener(new Animation.AnimationListener() {

        @Override
        public void onAnimationStart(Animation animation) {
            vibrator.vibrate(
            VibrationEffect.startComposition()
                .addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_SLOW_RISE, 0.3f)
                .addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_QUICK_FALL, 0.3f)
                .compose());
        }
        });

        collapseAnimation = AnimationUtils
                .loadAnimation(context, R.anim.collapse);
        collapseAnimation.setAnimationListener(new Animation.AnimationListener() {

            @Override
            public void onAnimationStart(Animation animation) {
                vibrator.vibrate(
                VibrationEffect.startComposition()
                    .addPrimitive(
                        VibrationEffect.Composition.PRIMITIVE_SLOW_RISE)
                    .compose());
            }
        });
    }

    @Override
    public void onClick(View view) {
        view.startAnimation(isExpanded ? collapseAnimation : expandAnimation);
        isExpanded = !isExpanded;
    }
}

Lắc lư (có vòng quay)

Một trong những nguyên tắc chính về phản hồi xúc giác là làm hài lòng người dùng. Một cách thú vị để giới thiệu hiệu ứng rung bất ngờ và dễ chịu là sử dụng PRIMITIVE_SPIN. Loại dữ liệu gốc này hiệu quả nhất khi được gọi nhiều lần. Việc nối nhiều vòng quay có thể tạo ra hiệu ứng lắc lư và không ổn định. Bạn có thể tăng cường hiệu ứng này bằng cách áp dụng tỷ lệ ngẫu nhiên trên từng hình ảnh gốc. Bạn cũng có thể thử nghiệm khoảng cách giữa các nguyên hàm quay liên tiếp. Hai vòng quay không có khoảng trống (0 ms ở giữa) tạo ra cảm giác xoay chặt. Việc tăng khoảng thời gian giữa các vòng quay từ 10 lên 50 ms sẽ tạo cảm giác xoay lỏng hơn và có thể được dùng để khớp với thời lượng của video hoặc ảnh động.

Không sử dụng khoảng thời gian dài hơn 100 ms, vì các vòng quay liên tiếp sẽ không còn tích hợp tốt và bắt đầu có cảm giác như các hiệu ứng riêng lẻ.

Dưới đây là ví dụ về một hình dạng đàn hồi bật trở lại sau khi được kéo xuống rồi thả ra. Hoạt ảnh được nâng cao bằng một cặp hiệu ứng xoay, được phát với cường độ khác nhau tương ứng với độ dịch chuyển của độ nảy.

Ảnh động về một hình dạng đàn hồi đang nảy
Biểu đồ dạng sóng rung đầu vào

Hình 3. Hình dạng sóng này thể hiện gia tốc đầu ra của độ rung trên một thiết bị.

Kotlin

@Composable
fun WobbleScreen() {
    // Control variables for the dragging and animating state of the elastic.
    var dragDistance by remember { mutableStateOf(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
        ),
    )

    if (isWobbling) {
        LaunchedEffect(Unit) {
            while (true) {
                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 = {
                    isWobbling = true
                    dragDistance = 0f
                },
                orientation = Orientation.Vertical,
                state = rememberDraggableState { delta ->
                    isWobbling = false
                    dragDistance += delta
                }
            )
    ) {
        // Draw the wobbling shape using the animated spring-like value.
        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)
}

Java

class AnimationListener implements DynamicAnimation.OnAnimationUpdateListener {
    private final Random vibrationRandom = new Random(seed);
    private final long lastVibrationUptime;

    @Override
    public void onAnimationUpdate(
        DynamicAnimation animation, float value, float velocity) {
        // 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.
        if (SystemClock.uptimeMillis() - lastVibrationUptime < VIBRATION_DURATION) {
            return;
        }

        float displacement = calculateRelativeDisplacement(value);

        // Use some sort of minimum displacement so the final few frames
        // of animation don't generate a vibration.
        if (displacement < SPIN_MIN_DISPLACEMENT) {
            return;
        }

        lastVibrationUptime = SystemClock.uptimeMillis();
        vibrator.vibrate(
        VibrationEffect.startComposition()
            .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN,
            nextSpinScale(displacement))
            .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN,
            nextSpinScale(displacement))
            .compose());
    }

    // Calculate a random scale for each spin to vary the full effect.
    float nextSpinScale(float displacement) {
        // 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.
        float randomOffset = vibrationRandom.nextFloat() * 0.2f - 0.1f
        return MathUtils.clamp(displacement + randomOffset, 0f, 1f)
    }
}

Bật nảy (có tiếng ồn)

Một ứng dụng nâng cao khác của hiệu ứng rung là mô phỏng các tương tác thực tế. PRIMITIVE_THUD có thể tạo hiệu ứng mạnh mẽ và âm vang, có thể kết hợp với hình ảnh trực quan của một tác động, chẳng hạn như trong video hoặc ảnh động, để tăng cường trải nghiệm tổng thể.

Dưới đây là ví dụ về ảnh động thả bóng được nâng cao bằng hiệu ứng tiếng ồn phát ra mỗi khi bóng bật ra khỏi đáy màn hình:

Ảnh động một quả bóng rơi xuống và nảy ra khỏi cuối màn hình.
Biểu đồ dạng sóng rung đầu vào.

Hình 4. Hình dạng sóng này thể hiện gia tốc đầu ra của độ rung trên một thiết bị.

Kotlin

enum class BallPosition {
    Start,
    End
}

@Composable
fun BounceScreen() {
    // Control variable for the state of the ball.
    var ballPosition by remember { mutableStateOf(BallPosition.Start) }
    var bounceCount by remember { mutableStateOf(0) }

    // Animation for the bouncing ball.
    var transitionData = updateTransitionData(ballPosition)
    val collisionData = updateCollisionData(transitionData)

    // Ball is about to contact floor, only vibrating once per collision.
    var hasVibratedForBallContact by remember { mutableStateOf(false) }
    if (collisionData.collisionWithFloor) {
        if (!hasVibratedForBallContact) {
        val vibrationScale = 0.7.pow(bounceCount++).toFloat()
        vibrator.vibrate(
            VibrationEffect.startComposition().addPrimitive(
            VibrationEffect.Composition.PRIMITIVE_THUD,
            vibrationScale
            ).compose()
        )
        hasVibratedForBallContact = true
        }
    } else {
        // Reset for next contact with floor.
        hasVibratedForBallContact = false
    }

    Screen() {
        Box(
        Modifier
            .fillMaxSize()
            .clickable {
            if (transitionData.isAtStart) {
                ballPosition = BallPosition.End
            } else {
                ballPosition = BallPosition.Start
                bounceCount = 0
            }
            },
        ) {
        // Build the ball UI based on the current state.
        BouncingBall(transitionData)
        }
    }
}

Java

class ClickListener implements View.OnClickListener {
    @Override
    public void onClick(View view) {
        view.animate()
        .translationY(targetY)
        .setDuration(3000)
        .setInterpolator(new BounceInterpolator())
        .setUpdateListener(new AnimatorUpdateListener() {

            boolean hasVibratedForBallContact = false;
            int bounceCount = 0;

            @Override
            public void onAnimationUpdate(ValueAnimator animator) {
            boolean valueBeyondThreshold = (float) animator.getAnimatedValue() > 0.98;
            if (valueBeyondThreshold) {
                if (!hasVibratedForBallContact) {
                float vibrationScale = (float) Math.pow(0.7, bounceCount++);
                vibrator.vibrate(
                    VibrationEffect.startComposition()
                    .addPrimitive(
                        VibrationEffect.Composition.PRIMITIVE_THUD,
                        vibrationScale)
                    .compose());
                hasVibratedForBallContact = true;
                }
            } else {
                // Reset for next contact with floor.
                hasVibratedForBallContact = false;
            }
            }
        });
    }
}

Hình dạng sóng rung với các bao bì

Quy trình tạo kiểu rung tuỳ chỉnh cho phép bạn kiểm soát biên độ rung để tạo hiệu ứng tăng và giảm mượt mà. Phần này giải thích cách tạo hiệu ứng xúc giác động bằng cách sử dụng các bao bì dạng sóng cho phép kiểm soát chính xác biên độ và tần số rung theo thời gian. Điều này cho phép bạn tạo ra trải nghiệm xúc giác phong phú và tinh tế hơn.

Kể từ Android 16 (API cấp 36), hệ thống sẽ cung cấp các API sau để tạo một bao bì dạng sóng rung bằng cách xác định một trình tự các điểm điều khiển:

  • BasicEnvelopeBuilder: Một phương pháp dễ tiếp cận để tạo hiệu ứng xúc giác không phụ thuộc vào phần cứng.
  • WaveformEnvelopeBuilder: Một phương pháp nâng cao hơn để tạo hiệu ứng xúc giác; yêu cầu bạn phải quen thuộc với phần cứng xúc giác.

Android không cung cấp phương án dự phòng cho hiệu ứng bao bì. Nếu bạn cần hỗ trợ về việc này, hãy hoàn tất các bước sau:

  1. Kiểm tra xem một thiết bị nhất định có hỗ trợ hiệu ứng bao phủ hay không bằng cách sử dụng Vibrator.areEnvelopeEffectsSupported().
  2. Tắt nhóm trải nghiệm nhất quán không được hỗ trợ hoặc sử dụng hình thức rung tuỳ chỉnh hoặc thành phần làm phương án dự phòng.

Để tạo các hiệu ứng bao phủ cơ bản hơn, hãy sử dụng BasicEnvelopeBuilder với các tham số sau:

  • Giá trị cường độ trong phạm vi \( [0, 1] \), đại diện cho cường độ rung được cảm nhận. Ví dụ: giá trị \( 0.5 \)được coi là một nửa cường độ tối đa toàn cầu mà thiết bị có thể đạt được.
  • Giá trị độ sắc nét trong phạm vi \( [0, 1] \), thể hiện độ rõ ràng của độ rung. Giá trị thấp hơn sẽ tạo ra độ rung mượt mà hơn, trong khi giá trị cao hơn sẽ tạo ra cảm giác sắc nét hơn.

  • Giá trị thời lượng, đại diện cho thời gian (tính bằng mili giây) cần thiết để chuyển đổi từ điểm điều khiển cuối cùng (tức là một cặp cường độ và độ sắc nét) sang điểm điều khiển mới.

Dưới đây là một ví dụ về dạng sóng tăng cường độ từ âm thấp đến âm cao, độ rung mạnh nhất trong 500 ms, sau đó giảm trở lại\( 0 \) (tắt) trong 100 ms.

vibrator.vibrate(VibrationEffect.BasicEnvelopeBuilder()
    .setInitialSharpness(0.0f)
    .addControlPoint(1.0f, 1.0f, 500)
    .addControlPoint(0.0f, 1.0f, 100)
    .build()
)

Nếu có kiến thức nâng cao hơn về tính năng haptics, bạn có thể xác định hiệu ứng bao phủ bằng cách sử dụng WaveformEnvelopeBuilder. Khi sử dụng đối tượng này, bạn có thể truy cập vào bản đồ tần số-tăng tốc đầu ra (FOAM) thông qua VibratorFrequencyProfile.

  • Giá trị biên độ trong phạm vi \( [0, 1] \), đại diện cho cường độ rung có thể đạt được ở tần số nhất định, do FOAM của thiết bị xác định. Ví dụ: giá trị \( 0.5 \) tạo ra một nửa gia tốc đầu ra tối đa có thể đạt được ở tần số nhất định.
  • Giá trị tần số, được chỉ định bằng Hertz.

  • Giá trị duration (thời lượng) thể hiện thời gian (tính bằng mili giây) cần thiết để chuyển đổi từ điểm điều khiển cuối cùng sang điểm điều khiển mới.

Mã sau đây cho thấy một dạng sóng mẫu xác định hiệu ứng rung 400 ms. Nó bắt đầu bằng một độ dốc biên độ 50 ms, từ tắt sang đầy, ở mức 60 Hz không đổi. Sau đó, tần số tăng lên 120 Hz trong 100 ms tiếp theo và vẫn ở mức đó trong 200 ms. Cuối cùng, biên độ giảm xuống \( 0 \)và tần số trở về 60 Hz trong 50 ms cuối cùng:

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()
)

Các phần sau đây cung cấp một số ví dụ về dạng sóng rung động với bao bì.

Lò xo nảy

Một mẫu trước đó sử dụng PRIMITIVE_THUD để mô phỏng các lượt tương tác thực tế với lượt thoát. API bao bì cơ bản cung cấp khả năng kiểm soát tốt hơn đáng kể, cho phép bạn điều chỉnh chính xác cường độ và độ sắc nét của độ rung. Điều này giúp phản hồi xúc giác tuân theo các sự kiện ảnh động chính xác hơn.

Dưới đây là ví dụ về một lò xo rơi tự do với ảnh động được nâng cao bằng hiệu ứng bao bì cơ bản được phát mỗi khi lò xo bật ra khỏi đáy màn hình:

Biểu đồ dạng sóng gia tốc đầu ra cho một dao động mô phỏng một lò xo nảy.

@Composable
fun BouncingSpringAnimation() {
  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) }
  var isAnimating by remember { mutableStateOf(false) }

  val (screenHeight, screenWidth) = getScreenDimensions(context)

  LaunchedEffect(isAnimating) {
    animationStartTime = System.currentTimeMillis()
    isAnimating = true

    while (isAnimating) {
      velocityY += GRAVITY
      springX += velocityX.dp
      springY += velocityY.dp

      // Handle bottom collision
      if (springY > screenHeight - FLOOR_HEIGHT - SPRING_HEIGHT / 2) {
        // Set the spring's y-position to the bottom bounce point, to keep it
        // above the floor.
        springY = screenHeight - FLOOR_HEIGHT - SPRING_HEIGHT / 2

        // Reverse the vertical velocity and apply damping to simulate a bounce.
        velocityY *= -BOUNCE_DAMPING
        bottomBounceCount++

        // Calculate the fade-out duration of the vibration based on the
        // vertical velocity.
        val fadeOutDuration =
            ((abs(velocityY) / GRAVITY) * FRAME_DELAY_MS).toLong()

        // Create a "boing" envelope vibration effect that fades out.
        vibrator.vibrate(
            VibrationEffect.BasicEnvelopeBuilder()
                // Starting from zero sharpness here, will simulate a smoother
                // "boing" effect.
                .setInitialSharpness(0f)

                // Add a control point to reach the desired intensity and
                // sharpness very quickly.
                .addControlPoint(intensity, sharpness, 20L)

                // Add a control point to fade out the vibration intensity while
                // maintaining sharpness.
                .addControlPoint(0f, sharpness, fadeOutDuration)
                .build()
        )

        // Decrease the intensity and sharpness of the vibration for subsequent
        // bounces, and reduce the multiplier to create a fading effect.
        intensity *= multiplier
        sharpness *= multiplier
        multiplier -= 0.1f
      }

      if (springX > screenWidth - SPRING_WIDTH / 2) {
        // Prevent the spring from moving beyond the right edge of the screen.
        springX = screenWidth - 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 < screenHeight + SPRING_HEIGHT ||
            springX < screenWidth + SPRING_WIDTH)
        && (velocityX >= 0.1f || velocityY >= 0.1f)
    }
  }

  Box(
    modifier = Modifier
      .fillMaxSize()
      .noRippleClickable {
        if (!isAnimating) {
          resetAnimation()
        }
      }
      .width(screenWidth)
      .height(screenHeight)
  ) {
    DrawSpring(mutableStateOf(springX), mutableStateOf(springY))
    DrawFloor()
    if (!isAnimating) {
      DrawText("Tap to restart")
    }
  }
}

Vụ phóng tên lửa

Một mẫu trước đó cho thấy cách sử dụng API phong bì cơ bản để mô phỏng phản ứng của lò xo nảy. WaveformEnvelopeBuilder mở khoá tính năng kiểm soát chính xác phạm vi tần số đầy đủ của thiết bị, cho phép các hiệu ứng xúc giác được tuỳ chỉnh cao. Bằng cách kết hợp dữ liệu này với dữ liệu FOAM, bạn có thể điều chỉnh độ rung cho phù hợp với các chức năng tần số cụ thể.

Dưới đây là ví dụ minh hoạ quá trình mô phỏng phóng tên lửa bằng cách sử dụng mẫu rung động động. Hiệu ứng này đi từ đầu ra tăng tốc tần số tối thiểu được hỗ trợ, 0,1 G, đến tần số cộng hưởng, luôn duy trì đầu vào biên độ 10%. Điều này cho phép hiệu ứng bắt đầu bằng một đầu ra mạnh mẽ và tăng cường độ và độ sắc nét được cảm nhận, mặc dù biên độ của tín hiệu điều khiển vẫn như cũ. Khi đạt đến độ cộng hưởng, tần số hiệu ứng sẽ giảm xuống mức tối thiểu, được cảm nhận là độ sắc nét và cường độ giảm dần. Điều này tạo ra cảm giác kháng lực ban đầu, sau đó là một sự giải phóng, mô phỏng việc phóng vào không gian.

Bạn không thể tạo hiệu ứng này bằng API bao bì cơ bản, vì API này loại bỏ thông tin dành riêng cho thiết bị về tần số cộng hưởng và đường cong gia tốc đầu ra. Việc tăng độ sắc nét có thể đẩy tần số tương đương vượt quá độ cộng hưởng, có thể gây ra sự sụt giảm gia tốc ngoài ý muốn.

Biểu đồ dạng sóng gia tốc đầu ra cho một dao động mô phỏng quá trình phóng tên lửa.

@Composable
fun RocketLaunchAnimation() {
  val context = LocalContext.current
  val screenHeight = remember { mutableFloatStateOf(0f) }
  var rocketPositionY by remember { mutableFloatStateOf(0f) }
  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)
        )
      ) {
        rocketPositionY = screenHeight.floatValue * value
      }
      animation.snapTo(0f)
      rocketPositionY = 0f;
      isLaunched = false;
    }
  }

  Box(
    modifier = Modifier
      .fillMaxSize()
      .noRippleClickable {
        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, 0.7f)
          isLaunched = true
        }
      }
      .background(Color(context.getColor(R.color.background)))
      .onSizeChanged { screenHeight.floatValue = it.height.toFloat() }
  ) {
    drawRocket(rocketPositionY)
  }
}

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 - rampUpDuration - minDurationMs

  vibrator.vibrate(
    VibrationEffect.WaveformEnvelopeBuilder()
      // Quickly reach the desired 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()
  )
}