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 phản hồi xúc giác để tạo các hiệu ứng tuỳ chỉnh ngoài dạng sóng rung tiêu chuẩn trong một ứng dụng Android.

Trang này có các ví dụ sau:

Để xem 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 theo các nguyên tắc thiết kế phản hồi xúc giác.

Sử dụng các giải pháp dự phòng để xử lý vấn đề 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:

  • Những chức năng nào của thiết bị là cần thiết để có hiệu ứng này
  • 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 về API phản hồi xúc giác của Android cung cấp thông tin chi tiết về cách kiểm tra xem các thành phần liên quan đến phản hồi xúc giác có được hỗ trợ hay không, để ứ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 các chức năng tiềm năng khác nhau.

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

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

  • Thiết bị có chế độ kiểm soát biên độ.

  • Thiết bị có chế độ rung cơ bản (bật/tắt) – tức là những thiết bị không có chế độ kiểm soát biên độ.

Nếu lựa chọn hiệu ứng xúc giác của ứng dụng có 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 đối với mọi thiết bị riêng lẻ.

Sử dụng các thành phần xúc giác cơ bản

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

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

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

Các mẫu rung thường được dùng trong chế độ xúc giác 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 làm thay đổi biên độ rung theo thời gian. Những hiệu ứng như vậy được gọi là dạng sóng.

Người dùng thường cảm nhận được hiệu ứng dạng sóng, nhưng những rung động 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 dần đến biên độ mục tiêu quá nhanh cũng có thể tạo ra tiếng ồn ù ù có thể nghe thấy. 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ề các mẫu rung:

Mẫu tăng dần số lượng

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

  1. Timings:một mảng gồm các khoảng thời gian (tính bằng mili giây) cho từng đoạn dạng sóng.
  2. Cường độ: cường độ rung mong muốn cho mỗi khoảng thời gian được chỉ định trong đối số đầu tiên, được biểu thị bằng một 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à cường độ 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.

Sau đây là ví dụ về dạng sóng nhấp nháy hai lần với khoảng dừng 350 mili giây giữa các lần nhấp nháy. Xung đầu tiên là một xung tăng dần mượt mà đến biên độ tối đa, còn xung thứ hai là một xung 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 lặp lại dạng sóng cho đến khi huỷ. Cách tạo dạng sóng lặp lại là đặt một tham số repeat không âm. Khi bạn phát một dạng sóng lặp lại, chế độ 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 và yêu cầu người dùng thực hiện hành độ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ó cơ chế dự phòng

Kiểm soát biên độ rung là một chức năng phụ thuộc vào phần cứng. Việc phát dạng sóng trên một thiết bị tầm 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 hỗ trợ 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 thay thế.

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 các thành phần rung

Phần này trình bày các cách kết hợp các chế độ 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 chế độ rung phong phú bằng cách sử dụng các chức 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 có biên độ và tần số khác nhau để 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.

Quy trình tạo các kiểu rung tuỳ chỉnh (đã 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 dần mượt mà. Tính năng phản hồi xúc giác phong phú cải thiện khái niệm này bằng cách khám phá dải tần số rộng hơn của bộ rung trên thiết bị để mang lại hiệu ứng mượt mà hơn nữa. Những dạng sóng này đặc biệt hiệu quả trong việc tạo hiệu ứng tăng dần hoặc giảm dần.

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

Android không cung cấp các giải pháp dự phòng cho những thành phần có các thành phần cơ bản 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 phản hồi xúc giác 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 thành phần cơ bản mà bạn đang sử dụng.

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

Bạn có thể xem thêm thông tin về cách kiểm tra khả năng hỗ trợ của thiết bị trong các phần sau.

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

Bạn có thể tạo các hiệu ứng rung kết hợp bằng VibrationEffect.Composition. Dưới đây là ví dụ về hiệu ứng tăng dần chậm, sau đó là hiệu ứng nhấp chuột 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());

Một thành phần được tạo bằng cách thêm các thành phần cơ bản để phát theo trình tự. Mỗi thành phần cơ bản cũng có thể mở rộng, vì vậy, bạn có thể kiểm soát biên độ rung do từng thành phần tạo ra. Thang đo được xác định là một giá trị từ 0 đến 1, trong đó 0 thực sự tương ứng với biên độ tối thiểu mà người dùng có thể (hầu như) cảm nhận được nguyên tắc này.

Tạo các biến thể trong các thành phần rung

Nếu bạn muốn tạo một phiên bản yếu và một phiên bản mạnh của cùng một thành phần cơ bản, hãy tạo tỷ lệ sức mạnh 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ố gắng tạo nhiều hơn 3 mức cường độ của cùng một thành phần cơ bản, vì chúng không khác biệt về cảm quan. Ví dụ: sử dụng các tỷ lệ 0, 5, 0, 7 và 1, 0 để tạo các phiên bản có cường độ thấp, trung bình và cao của một thành phần cơ bản.

Thêm khoảng trống giữa các thành phần rung

Thành phần này cũng có thể chỉ định độ trễ được thêm vào giữa các thành phần cơ bản liên tiếp. Độ trễ này được biểu thị bằng mili giây kể từ khi kết thúc thành phần cơ bản trước đó. Nhìn chung, khoảng trống từ 5 đến 10 mili giây giữa hai thành phần cơ bản là quá ngắn để có thể phát hiện. Sử dụng khoảng trống có độ dài khoảng 50 mili giây trở lên nếu bạn muốn tạo khoảng trống có thể phân biệt được giữa hai thành phần cơ bản. Sau đâ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 những thành phần cơ bản nào được hỗ trợ

Bạn có thể dùng các API sau để xác minh khả năng hỗ trợ của thiết bị đối với các nguyên tắ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 thành phần cơ bản rồi quyết định thành phần nào cần kết hợp dựa trên mức độ hỗ trợ của 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ề các 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, lấy từ ứng dụng mẫu về phản hồi xúc giác trên GitHub.

Chống lại (với số lượng đánh dấu thấp)

Bạn có thể kiểm soát biên độ của chế độ rung cơ bản để truyền tải thông tin 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ó khoảng cách gần để tạo hiệu ứng tăng dần mượt mà cho một thành phần cơ bản. Độ trễ giữa các nguyên hàm 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ề một ảnh động của khung hiển thị do cử chỉ kéo kiểm soát và được tăng cường bằng phản hồi xúc giác.

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

Hình 1. Dạng sóng này biểu thị gia tốc đầu ra của chế độ rung trên 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 (có tăng và giảm)

Có 2 nguyên hàm để tăng cường cường độ rung cảm nhận được: PRIMITIVE_QUICK_RISEPRIMITIVE_SLOW_RISE. Cả hai đều đạt được cùng một mục tiêu, nhưng với thời lượng khác nhau. Chỉ có một dữ liệu nguyên thuỷ để giảm tốc độ, PRIMITIVE_QUICK_FALL. Các thành phần cơ bản này hoạt động hiệu quả hơn khi kết hợp với nhau để tạo ra một đoạn sóng có cường độ tăng lên rồi giảm dần. Bạn có thể căn chỉnh các thành phần cơ bản được mở rộng để tránh biên độ tăng đột ngột giữa các thành phần đó, điều này cũng có tác dụng 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, việc làm cho phần tăng ngắn hơn phần giảm có thể được dùng để chuyển sự chú ý sang phần giảm.

Sau đây là ví dụ về ứng dụng của 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ể làm tăng cảm giác mở rộng trong quá trình tạo ảnh động. Sự kết hợp giữa hiệu ứng tăng và giảm giúp nhấn mạnh việc thu gọn ở cuối ảnh động.

Ảnh động về một vòng tròn đang mở rộng.
Đồ thị của dạng sóng rung động đầu vào.

Hình 2. Dạng sóng này biểu thị gia tốc đầu ra của chế độ rung trên 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ó xoay)

Một trong những nguyên tắc quan trọng 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ờ nhưng dễ chịu là sử dụng PRIMITIVE_SPIN. Nguyên tắc này hiệu quả nhất khi được gọi nhiều lần. Nhiều vòng quay được nối với nhau có thể tạo ra hiệu ứng rung lắc và không ổn định. Bạn có thể tăng cường hiệu ứng này hơn nữa bằng cách áp dụng một tỷ lệ thu phóng ngẫu nhiên cho từng thành phần cơ bản. Bạn cũng có thể thử nghiệm khoảng cách giữa các phần tử cơ bản xoay liên tiếp. Hai lần xoay không có khoảng trống (0 mili giây ở giữa) sẽ tạo cảm giác xoay nhanh. Việc tăng khoảng cách giữa các lần xoay từ 10 mili giây lên 50 mili giây sẽ tạo cảm giác xoay lỏng lẻo hơn và có thể dùng để điều chỉnh cho phù hợp với thời lượng của video hoặc ảnh động.

Đừng sử dụng khoảng trống dài hơn 100 mili giây, 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 bị kéo xuống rồi thả ra. Hoạt ảnh được cải thiện bằng một cặp hiệu ứng xoay, được phát với cường độ khác nhau, tỷ lệ thuận với độ lệch của chuyển động bật.

Ảnh động về một hình dạng đàn hồi đang nảy lên
Đồ thị dạng sóng rung đầu vào

Hình 3. Dạng sóng này biểu thị gia tốc đầu ra của chế độ rung trên 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 động)

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

Sau đây là ví dụ về ảnh động thả bóng được tăng cường bằng hiệu ứng tiếng bịch mỗi khi quả bóng bật lên từ cuối màn hình:

Ảnh động một quả bóng rơi xuống và nảy lên từ cuối màn hình.
Đồ thị dạng sóng rung đầu vào.

Hình 4. Dạng sóng này biểu thị gia tốc đầu ra của chế độ rung trên 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;
            }
            }
        });
    }
}

Dạng sóng rung có bao

Quy trình tạo các 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 bao 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 cung cấp các API sau để tạo một phong bì dạng sóng rung bằng cách xác định một chuỗi các điểm kiểm soát:

  • BasicEnvelopeBuilder: Một phương pháp dễ tiếp cận để tạo hiệu ứng xúc giác độc lập với 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 các giải pháp dự phòng cho hiệu ứng phong bì. Nếu bạn cần được hỗ trợ, 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 hay không bằng cách sử dụng Vibrator.areEnvelopeEffectsSupported().
  2. Tắt bộ trải nghiệm nhất quán không được hỗ trợ hoặc sử dụng các kiểu 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 cơ bản hơn cho phong bì, hãy sử dụng BasicEnvelopeBuilder với các tham số sau:

  • Giá trị intensity trong phạm vi \( [0, 1] \), đại diện cho cường độ cảm nhận được của rung động. 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 độ sắc nét của chế độ 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 rung mạnh hơn.

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

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

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 chuyên sâu hơn về phản hồi xúc giác, bạn có thể xác định các hiệu ứng bao bằng WaveformEnvelopeBuilder. Khi sử dụng đối tượng này, bạn có thể truy cập vào ánh xạ tần số sang gia tốc đầu ra (FOAM) thông qua VibratorFrequencyProfile.

  • Một giá trị biên độ trong phạm vi \( [0, 1] \), biểu thị 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ố đã cho.
  • Giá trị tần số, được chỉ định bằng Hertz.

  • Giá trị 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 kiểm soát cuối cùng sang điểm kiểm soát mới.

Đoạn mã sau đây cho thấy một ví dụ về dạng sóng xác định hiệu ứng rung 400 mili giây. Quá trình này bắt đầu bằng một đường dốc biên độ 50 mili giây, từ tắt đến đầy, ở mức 60 Hz không đổi. Sau đó, tần số tăng lên 120 Hz trong 100 mili giây tiếp theo và duy trì ở mức đó trong 200 mili giây. Cuối cùng, biên độ giảm xuống \( 0 \)và tần số trở về 60 Hz trong 50 mili giây 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 có bao.

Lò xo bật lên

Một mẫu trước đó sử dụng PRIMITIVE_THUD để mô phỏng các hoạt động tương tác vật lý khi nảy. API phong bì cơ bản mang lại khả năng kiểm soát chính xác 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 chế độ rung. Điều này dẫn đến phản hồi xúc giác chính xác hơn theo các sự kiện có hiệu ứng động.

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

Ảnh động lò xo rơi xuống và bật lên từ cuối màn hình.
Đồ thị dạng sóng rung đầu vào.

Hình 5. Một biểu đồ dạng sóng tăng tốc đầu ra cho một chế độ rung mô phỏng lò xo bật.

@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 bao thư cơ bản để mô phỏng phản ứng của lò xo đàn hồi. WaveformEnvelopeBuilder mở khoá khả năng kiểm soát chính xác toàn bộ dải tần số của thiết bị, cho phép tạo ra 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 khả năng tần số cụ thể.

Sau đây là một ví dụ minh hoạ mô phỏng quá trình phóng tên lửa bằng cách sử dụng một kiểu rung động linh hoạt. Hiệu ứng này có đầu ra gia tốc tần số tối thiểu được hỗ trợ là 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 với đầu ra tương đối mạnh và tăng cường độ và độ sắc nét cảm nhận được, mặc dù biên độ đ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à cường độ và độ sắc nét giảm. Điều này tạo ra cảm giác có lực cản ban đầu, sau đó là lực đẩy, mô phỏng một vụ phóng vào không gian.

Không thể tạo hiệu ứng này bằng API bao thư cơ bản, vì API này sẽ trừu tượng hoá 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ó khả năng gây ra hiện tượng giảm tốc ngoài ý muốn.

Ảnh động một chiếc tàu vũ trụ cất cánh từ phía dưới màn hình.
Đồ thị dạng sóng rung đầu vào.

Hình 6. Biểu đồ dạng sóng gia tốc đầu ra cho một rung độ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()
  )
}