ایجاد جلوه های لمسی سفارشی

این صفحه نمونه‌هایی از نحوه استفاده از APIهای لمسی مختلف برای ایجاد جلوه‌های سفارشی فراتر از شکل موج‌های ارتعاش استاندارد در یک برنامه اندروید را پوشش می‌دهد.

این صفحه شامل مثال‌های زیر است:

برای مثال‌های بیشتر، به بخش «افزودن بازخورد لمسی به رویدادها» مراجعه کنید و همیشه اصول طراحی لمسی را دنبال کنید.

از fallbackها برای مدیریت سازگاری دستگاه استفاده کنید

هنگام اجرای هرگونه جلوه سفارشی، موارد زیر را در نظر بگیرید:

  • کدام قابلیت‌های دستگاه برای این اثر مورد نیاز است؟
  • وقتی دستگاه قادر به پخش افکت نیست چه باید کرد؟

مرجع API مربوط به سیستم‌های لمسی اندروید، جزئیاتی در مورد چگونگی بررسی پشتیبانی از اجزای درگیر در سیستم‌های لمسی ارائه می‌دهد تا برنامه شما بتواند یک تجربه کلی پایدار ارائه دهد.

بسته به مورد استفاده شما، ممکن است بخواهید جلوه‌های سفارشی را غیرفعال کنید یا جلوه‌های سفارشی جایگزین را بر اساس قابلیت‌های بالقوه مختلف ارائه دهید.

برای کلاس‌های سطح بالای زیر از قابلیت‌های دستگاه برنامه‌ریزی کنید:

  • اگر از عناصر اولیه لمسی استفاده می‌کنید: دستگاه‌هایی که از آن عناصر اولیه مورد نیاز جلوه‌های سفارشی پشتیبانی می‌کنند. (برای جزئیات بیشتر در مورد عناصر اولیه به بخش بعدی مراجعه کنید.)

  • دستگاه‌هایی با کنترل دامنه .

  • دستگاه‌هایی با پشتیبانی اولیه از لرزش (روشن/خاموش) - به عبارت دیگر، دستگاه‌هایی که فاقد کنترل دامنه هستند.

اگر انتخاب افکت لمسی برنامه شما این دسته‌ها را در نظر بگیرد، تجربه کاربری لمسی آن باید برای هر دستگاهی قابل پیش‌بینی باقی بماند.

استفاده از عناصر اولیه لمسی

اندروید شامل چندین تابع لمسی اولیه است که هم از نظر دامنه و هم از نظر فرکانس متفاوت هستند. شما می‌توانید از یک تابع اولیه به تنهایی یا چندین تابع اولیه را به صورت ترکیبی برای دستیابی به جلوه‌های لمسی غنی استفاده کنید.

  • برای فواصل قابل تشخیص بین دو داده اولیه، از تأخیرهای ۵۰ میلی‌ثانیه یا بیشتر استفاده کنید، و در صورت امکان، مدت زمان داده اولیه را نیز در نظر بگیرید.
  • از مقیاس‌هایی استفاده کنید که نسبت اختلاف آنها ۱.۴ یا بیشتر باشد تا تفاوت شدت بهتر درک شود.
  • از مقیاس‌های ۰.۵، ۰.۷ و ۱.۰ برای ایجاد نسخه‌های کم‌شدت، متوسط ​​و پرشدت از یک شیء اولیه استفاده کنید.

ایجاد الگوهای ارتعاش سفارشی

الگوهای ارتعاش اغلب در لمس‌های توجه محور، مانند اعلان‌ها و آهنگ‌های زنگ، استفاده می‌شوند. سرویس Vibrator می‌تواند الگوهای ارتعاش طولانی را پخش کند که دامنه ارتعاش را با گذشت زمان تغییر می‌دهد. چنین جلوه‌هایی شکل موج نامیده می‌شوند.

اثرات شکل موج معمولاً قابل درک هستند، اما ارتعاشات ناگهانی و طولانی اگر در یک محیط آرام اجرا شوند، می‌توانند کاربر را وحشت‌زده کنند. افزایش خیلی سریع به دامنه هدف نیز ممکن است صداهای وزوز قابل شنیدن ایجاد کند. الگوهای شکل موج را طوری طراحی کنید که انتقال دامنه را نرم کنند تا اثرات افزایش و کاهش دامنه ایجاد شود.

نمونه‌هایی از الگوهای ارتعاش

بخش‌های زیر چندین نمونه از الگوهای ارتعاش را ارائه می‌دهند:

الگوی افزایش تدریجی

شکل موج‌ها به صورت VibrationEffect با سه پارامتر نمایش داده می‌شوند:

  1. زمان‌بندی‌ها: آرایه‌ای از مدت زمان‌ها، بر حسب میلی‌ثانیه، برای هر بخش از شکل موج.
  2. دامنه‌ها: دامنه ارتعاش مورد نظر برای هر مدت زمان مشخص شده در آرگومان اول، که با یک مقدار صحیح از ۰ تا ۲۵۵ نمایش داده می‌شود، که ۰ نشان دهنده حالت خاموش ویبراتور و ۲۵۵ حداکثر دامنه دستگاه است.
  3. شاخص تکرار: شاخصی در آرایه که در اولین آرگومان برای شروع تکرار شکل موج مشخص شده است، یا -۱ اگر قرار است الگو فقط یک بار پخش شود.

در اینجا یک شکل موج نمونه وجود دارد که دو بار با مکث ۳۵۰ میلی‌ثانیه بین پالس‌ها پالس می‌دهد. پالس اول یک شیب ملایم تا حداکثر دامنه است و پالس دوم یک شیب سریع برای حفظ حداکثر دامنه است. توقف در انتها با مقدار منفی شاخص تکرار تعریف می‌شود.

کاتلین

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

جاوا

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

الگوی تکرارشونده

شکل موج‌ها همچنین می‌توانند تا زمان لغو شدن، به طور مکرر پخش شوند. روش ایجاد یک شکل موج تکرارشونده، تنظیم یک پارامتر repeat غیرمنفی است. وقتی یک شکل موج تکرارشونده را پخش می‌کنید، لرزش تا زمانی که به طور صریح در سرویس لغو شود، ادامه می‌یابد:

کاتلین

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

جاوا

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

این برای رویدادهای متناوب که نیاز به اقدام کاربر برای تأیید آن دارند بسیار مفید است. نمونه‌هایی از چنین رویدادهایی شامل تماس‌های تلفنی ورودی و آلارم‌های فعال شده است.

الگو با قابلیت بازگشت به عقب

کنترل دامنه ارتعاش یک قابلیت وابسته به سخت‌افزار است. پخش یک شکل موج در یک دستگاه رده پایین بدون این قابلیت باعث می‌شود دستگاه برای هر ورودی مثبت در آرایه دامنه، با حداکثر دامنه بلرزد. اگر برنامه شما نیاز به تطبیق با چنین دستگاه‌هایی دارد، یا از الگویی استفاده کنید که هنگام پخش در آن شرایط، جلوه وزوز ایجاد نکند، یا یک الگوی روشن/خاموش ساده‌تر طراحی کنید که بتواند به عنوان جایگزین پخش شود.

کاتلین

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

جاوا

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

ترکیب‌های ارتعاشی ایجاد کنید

این بخش روش‌هایی را برای ترکیب ارتعاشات به جلوه‌های سفارشی طولانی‌تر و پیچیده‌تر ارائه می‌دهد و فراتر از آن به بررسی لمس‌های غنی با استفاده از قابلیت‌های سخت‌افزاری پیشرفته‌تر می‌پردازد. می‌توانید از ترکیبی از جلوه‌هایی که دامنه و فرکانس متفاوتی دارند، برای ایجاد جلوه‌های لمسی پیچیده‌تر در دستگاه‌هایی با محرک‌های لمسی که پهنای باند فرکانسی وسیع‌تری دارند، استفاده کنید.

فرآیند ایجاد الگوهای ارتعاش سفارشی ، که قبلاً در این صفحه توضیح داده شده است، نحوه کنترل دامنه ارتعاش را برای ایجاد جلوه‌های روان افزایش و کاهش توضیح می‌دهد. فناوری لمسی غنی با بررسی محدوده فرکانس وسیع‌تر ویبراتور دستگاه، این مفهوم را بهبود می‌بخشد تا این جلوه را روان‌تر کند. این شکل موج‌ها به ویژه در ایجاد جلوه افزایش یا کاهش مؤثر هستند.

اجزای اولیه ترکیب‌بندی که قبلاً در این صفحه توضیح داده شده‌اند، توسط سازنده دستگاه پیاده‌سازی شده‌اند. آن‌ها ارتعاشی واضح، کوتاه و دلپذیر ارائه می‌دهند که با اصول لمسی برای لمس واضح همسو است. برای جزئیات بیشتر در مورد این قابلیت‌ها و نحوه کار آن‌ها، به مقدمه‌ای بر محرک‌های ارتعاشی مراجعه کنید.

اندروید برای ترکیب‌هایی که از مقادیر اولیه پشتیبانی نمی‌کنند، fallback ارائه نمی‌دهد. بنابراین، مراحل زیر را انجام دهید:

  1. قبل از فعال کردن فناوری لمسی پیشرفته، بررسی کنید که دستگاه مورد نظر از تمام فناوری‌های اولیه‌ای که استفاده می‌کنید پشتیبانی می‌کند یا خیر.

  2. مجموعه‌ی سازگار از تجربیاتی که پشتیبانی نمی‌شوند را غیرفعال کنید، نه فقط جلوه‌هایی که فاقد یک عنصر اولیه هستند.

اطلاعات بیشتر در مورد نحوه بررسی پشتیبانی دستگاه در بخش‌های بعدی نشان داده شده است.

ایجاد جلوه‌های ارتعاشی ترکیبی

شما می‌توانید با استفاده از VibrationEffect.Composition جلوه‌های ارتعاشی ترکیبی ایجاد کنید. در اینجا مثالی از یک جلوه با افزایش تدریجی و به دنبال آن یک جلوه کلیک تیز آورده شده است:

کاتلین

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

جاوا

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

یک قطعه موسیقی با اضافه کردن قطعات اولیه‌ای که باید به ترتیب نواخته شوند، ساخته می‌شود. هر قطعه اولیه نیز قابل مقیاس‌بندی است، بنابراین می‌توانید دامنه ارتعاش تولید شده توسط هر یک از آنها را کنترل کنید. مقیاس به عنوان مقداری بین ۰ و ۱ تعریف می‌شود، که در آن ۰ در واقع به حداقل دامنه‌ای اشاره دارد که در آن این قطعه اولیه می‌تواند (به سختی) توسط کاربر احساس شود.

ایجاد انواع مختلف در شکل‌های اولیه ارتعاش

اگر می‌خواهید یک نسخه ضعیف و قوی از یک شکل اولیه ایجاد کنید، نسبت‌های قدرت ۱.۴ یا بیشتر ایجاد کنید تا تفاوت در شدت به راحتی قابل درک باشد. سعی نکنید بیش از سه سطح شدت از یک شکل اولیه ایجاد کنید، زیرا از نظر ادراکی متمایز نیستند. به عنوان مثال، از مقیاس‌های ۰.۵، ۰.۷ و ۱.۰ برای ایجاد نسخه‌های با شدت کم، متوسط ​​و زیاد از یک شکل اولیه استفاده کنید.

اضافه کردن فاصله بین مقادیر اولیه ارتعاش

این ترکیب همچنین می‌تواند تأخیرهایی را که باید بین عناصر اولیه متوالی اضافه شوند، مشخص کند. این تأخیر از پایان عنصر اولیه قبلی بر حسب میلی‌ثانیه بیان می‌شود. به طور کلی، فاصله ۵ تا ۱۰ میلی‌ثانیه بین دو عنصر اولیه برای تشخیص بسیار کوتاه است. اگر می‌خواهید فاصله قابل تشخیصی بین دو عنصر اولیه ایجاد کنید، از فاصله‌ای در حدود ۵۰ میلی‌ثانیه یا بیشتر استفاده کنید. در اینجا مثالی از یک ترکیب با تأخیرها آورده شده است:

کاتلین

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

جاوا

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

بررسی کنید که از کدام نوع داده اولیه پشتیبانی می‌شود

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

جاوا

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

همچنین می‌توان چندین نوع داده اولیه را بررسی کرد و سپس بر اساس سطح پشتیبانی دستگاه، تصمیم گرفت کدام یک را ترکیب کند:

کاتلین

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

جاوا

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

نمونه‌هایی از ترکیبات ارتعاشی

بخش‌های بعدی چندین نمونه از ترکیب‌های ارتعاشی را ارائه می‌دهند که از برنامه نمونه لمسی در GitHub گرفته شده‌اند.

مقاومت (با تیک‌های پایین)

شما می‌توانید دامنه ارتعاش اولیه را کنترل کنید تا بازخورد مفیدی را به عملی که در حال انجام است، منتقل کنید. مقادیر مقیاس با فاصله نزدیک می‌توانند برای ایجاد یک اثر اوج‌گیری نرم از یک اولیه استفاده شوند. تأخیر بین اولیه‌های متوالی نیز می‌تواند به صورت پویا بر اساس تعامل کاربر تنظیم شود. این موضوع در مثال زیر از یک انیمیشن نما که توسط یک حرکت کشیدن کنترل می‌شود و با لمس تقویت شده است، نشان داده شده است.

انیمیشن کشیدن یک دایره به پایین.
نمودار شکل موج ارتعاش ورودی.

شکل ۱. این شکل موج، شتاب خروجی ارتعاش روی یک دستگاه را نشان می‌دهد.

کاتلین

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

جاوا

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

گسترش (با افزایش و کاهش)

دو تابع اولیه برای افزایش شدت ارتعاش درک شده وجود دارد: PRIMITIVE_QUICK_RISE و PRIMITIVE_SLOW_RISE . هر دو به یک هدف می‌رسند، اما با مدت زمان‌های مختلف. فقط یک تابع اولیه برای کاهش شدت وجود دارد، PRIMITIVE_QUICK_FALL . این توابع اولیه برای ایجاد یک بخش شکل موج که شدت آن افزایش می‌یابد و سپس از بین می‌رود، با هم بهتر عمل می‌کنند. می‌توانید توابع اولیه مقیاس‌بندی شده را تراز کنید تا از جهش‌های ناگهانی در دامنه بین آنها جلوگیری شود، که این امر همچنین برای افزایش مدت زمان کلی اثر خوب عمل می‌کند. از نظر ادراکی، مردم همیشه بخش در حال افزایش را بیشتر از بخش در حال کاهش متوجه می‌شوند، بنابراین کوتاه‌تر کردن بخش در حال افزایش نسبت به بخش در حال کاهش می‌تواند برای تغییر تأکید به سمت بخش در حال کاهش استفاده شود.

در اینجا مثالی از کاربرد این ترکیب برای گسترش و فروپاشی یک دایره آورده شده است. جلوه افزایش می‌تواند حس گسترش را در طول انیمیشن تقویت کند. ترکیب جلوه‌های افزایش و کاهش به تأکید بر فروپاشی در پایان انیمیشن کمک می‌کند.

انیمیشن یک دایره در حال گسترش.
نمودار شکل موج ارتعاش ورودی.

شکل ۲. این شکل موج، شتاب خروجی ارتعاش روی یک دستگاه را نشان می‌دهد.

کاتلین

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

جاوا

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

تلوتلو خوردن (با چرخش)

یکی از اصول کلیدی حس لامسه، ایجاد لذت در کاربران است. یک راه سرگرم‌کننده برای ایجاد یک اثر ارتعاش غیرمنتظره و دلپذیر، استفاده PRIMITIVE_SPIN است. این تابع اولیه زمانی بیشترین تأثیر را دارد که بیش از یک بار فراخوانی شود. چرخش‌های چندگانه به هم پیوسته می‌توانند یک اثر لرزان و ناپایدار ایجاد کنند که می‌توان با اعمال مقیاس‌بندی تا حدودی تصادفی روی هر تابع اولیه، آن را بیشتر بهبود بخشید. همچنین می‌توانید با فاصله بین دو تابع اولیه چرخش متوالی آزمایش کنید. دو چرخش بدون هیچ فاصله‌ای (0 میلی‌ثانیه بین آنها) یک حس چرخش فشرده ایجاد می‌کند. افزایش فاصله بین دو چرخش از 10 به 50 میلی‌ثانیه منجر به حس چرخش آزادتر می‌شود و می‌تواند برای مطابقت با مدت زمان یک ویدیو یا انیمیشن استفاده شود.

از فاصله زمانی طولانی‌تر از ۱۰۰ میلی‌ثانیه استفاده نکنید، زیرا چرخش‌های متوالی دیگر به خوبی ادغام نمی‌شوند و مانند اثرات جداگانه به نظر می‌رسند.

در اینجا مثالی از یک شکل الاستیک وجود دارد که پس از کشیدن و رها کردن به پایین، به حالت اولیه خود برمی‌گردد. این انیمیشن با یک جفت جلوه چرخشی بهبود یافته است که با شدت‌های مختلفی متناسب با جابجایی جهش، پخش می‌شوند.

انیمیشن بالا و پایین پریدن یک شکل الاستیک
نمودار شکل موج ارتعاش ورودی

شکل ۳. این شکل موج، شتاب خروجی ارتعاش روی یک دستگاه را نشان می‌دهد.

کاتلین

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

جاوا

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

پرش (با ضربات)

یکی دیگر از کاربردهای پیشرفته‌ی جلوه‌های ارتعاشی، شبیه‌سازی تعاملات فیزیکی است. PRIMITIVE_THUD می‌تواند یک جلوه‌ی قوی و طنین‌انداز ایجاد کند که می‌تواند با تجسم یک ضربه، مثلاً در یک ویدیو یا انیمیشن، جفت شود تا تجربه‌ی کلی را تقویت کند.

در اینجا مثالی از انیمیشن افتادن توپ که با هر بار برخورد توپ به پایین صفحه، یک افکت ضربه پخش می‌شود، آورده شده است:

انیمیشن یک توپ رها شده که از پایین صفحه نمایش به بالا و پایین می‌پرد.
نمودار شکل موج ارتعاش ورودی.

شکل ۴. این شکل موج، شتاب خروجی ارتعاش روی یک دستگاه را نشان می‌دهد.

کاتلین

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

جاوا

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

شکل موج ارتعاش با پوشش‌ها

فرآیند ایجاد الگوهای ارتعاش سفارشی به شما امکان می‌دهد دامنه ارتعاش را کنترل کنید تا جلوه‌های نرمی از افزایش و کاهش ارتعاش ایجاد کنید. این بخش نحوه ایجاد جلوه‌های لمسی پویا را با استفاده از پوشش‌های شکل موج توضیح می‌دهد که امکان کنترل دقیق دامنه و فرکانس ارتعاش را در طول زمان فراهم می‌کنند. این به شما امکان می‌دهد تجربیات لمسی غنی‌تر و ظریف‌تری ایجاد کنید.

با شروع از اندروید ۱۶ (سطح API ۳۶)، سیستم APIهای زیر را برای ایجاد یک پوشش شکل موج ارتعاش با تعریف دنباله ای از نقاط کنترل ارائه می‌دهد:

  • BasicEnvelopeBuilder : یک رویکرد قابل دسترس برای ایجاد جلوه‌های لمسی مستقل از سخت‌افزار.
  • WaveformEnvelopeBuilder : رویکردی پیشرفته‌تر برای ایجاد جلوه‌های لمسی؛ نیاز به آشنایی با سخت‌افزار لمسی دارد.

اندروید برای جلوه‌های پوششی، جایگزین ارائه نمی‌دهد. اگر به این پشتیبانی نیاز دارید، مراحل زیر را انجام دهید:

  1. با استفاده از Vibrator.areEnvelopeEffectsSupported() بررسی کنید که یک دستگاه مشخص از جلوه‌های پوششی پشتیبانی می‌کند.
  2. مجموعه‌ی ثابتی از تجربیات پشتیبانی‌نشده را غیرفعال کنید، یا از الگوها یا ترکیب‌های ارتعاش سفارشی به عنوان جایگزین‌های جایگزین استفاده کنید.

برای ایجاد جلوه‌های پوششی ساده‌تر، از BasicEnvelopeBuilder با این پارامترها استفاده کنید:

  • مقدار شدت در محدوده \( [0, 1] \)که نشان دهنده قدرت درک شده از ارتعاش است. به عنوان مثال، مقداری از \( 0.5 \)به عنوان نیمی از حداکثر شدت کلی قابل دستیابی توسط دستگاه در نظر گرفته می‌شود.
  • مقدار وضوح در محدوده \( [0, 1] \)که نشان دهنده تردی ارتعاش است. مقادیر پایین‌تر به ارتعاشات نرم‌تر تبدیل می‌شوند، در حالی که مقادیر بالاتر حس تیزتری ایجاد می‌کنند.

  • یک مقدار مدت زمان ، که نشان دهنده زمان (بر حسب میلی ثانیه) برای انتقال از آخرین نقطه کنترل - یعنی یک جفت شدت و وضوح - به نقطه جدید است.

در اینجا یک شکل موج نمونه آورده شده است که شدت را از یک ارتعاش با فرکانس پایین به یک ارتعاش با فرکانس بالا و حداکثر قدرت در طول ۵۰۰ میلی‌ثانیه افزایش می‌دهد و سپس به حالت قبل برمی‌گردد.\( 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 \) نصف حداکثر شتاب خروجی قابل دستیابی در فرکانس داده شده را تولید می‌کند.
  • مقدار فرکانس ، مشخص شده بر حسب هرتز.

  • یک مقدار مدت زمان ، که نشان دهنده زمان (بر حسب میلی ثانیه) برای انتقال از آخرین نقطه کنترل به نقطه کنترل جدید است.

کد زیر یک شکل موج نمونه را نشان می‌دهد که یک اثر ارتعاشی ۴۰۰ میلی‌ثانیه‌ای را تعریف می‌کند. این شکل موج با یک شیب دامنه ۵۰ میلی‌ثانیه‌ای، از حالت خاموش تا حالت کامل، با فرکانس ثابت ۶۰ هرتز شروع می‌شود. سپس، فرکانس در ۱۰۰ میلی‌ثانیه بعدی تا ۱۲۰ هرتز افزایش می‌یابد و به مدت ۲۰۰ میلی‌ثانیه در آن سطح باقی می‌ماند. در نهایت، دامنه به ... کاهش می‌یابد. \( 0 \)و فرکانس در ۵۰ میلی‌ثانیه گذشته به ۶۰ هرتز برمی‌گردد:

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 پوشش پایه ، کنترل بسیار دقیق‌تری را ارائه می‌دهد و به شما امکان می‌دهد شدت و وضوح ارتعاش را دقیقاً تنظیم کنید. این امر منجر به بازخورد لمسی می‌شود که با دقت بیشتری رویدادهای متحرک را دنبال می‌کند.

در اینجا مثالی از یک فنر در حال سقوط آزاد را مشاهده می‌کنید که انیمیشن آن با یک افکت پوششی ساده که هر بار فنر از پایین صفحه به پایین می‌پرد، پخش می‌شود، بهبود یافته است:

انیمیشن یک فنر افتاده که از پایین صفحه نمایش بالا و پایین می‌پرد.
نمودار شکل موج ارتعاش ورودی.

شکل ۵. نمودار شکل موج شتاب خروجی برای ارتعاشی که فنر جهنده را شبیه‌سازی می‌کند.

@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")
    }
  }
}

پرتاب موشک

نمونه قبلی نحوه استفاده از API اولیه envelope را برای شبیه‌سازی یک واکنش فنری فنری نشان می‌دهد. WaveformEnvelopeBuilder کنترل دقیقی بر روی کل محدوده فرکانس دستگاه ایجاد می‌کند و جلوه‌های لمسی بسیار سفارشی را ممکن می‌سازد. با ترکیب این با داده‌های FOAM، می‌توانید ارتعاشات را با قابلیت‌های فرکانسی خاص تنظیم کنید.

در اینجا مثالی آورده شده است که شبیه‌سازی پرتاب موشک را با استفاده از الگوی ارتعاش پویا نشان می‌دهد. این اثر از حداقل خروجی شتاب فرکانس پشتیبانی شده، 0.1 G، به فرکانس رزونانس می‌رود و همیشه ورودی دامنه 10٪ را حفظ می‌کند. این امر به اثر اجازه می‌دهد تا با یک خروجی نسبتاً قوی شروع شود و شدت و وضوح درک شده را افزایش دهد، حتی اگر دامنه محرک یکسان باشد. با رسیدن به رزونانس، فرکانس اثر به حداقل کاهش می‌یابد، که به عنوان شدت و وضوح نزولی درک می‌شود. این امر باعث ایجاد حس مقاومت اولیه و به دنبال آن رها شدن می‌شود و پرتاب به فضا را تقلید می‌کند.

این اثر با API اولیه envelope امکان‌پذیر نیست، زیرا اطلاعات خاص دستگاه در مورد فرکانس رزونانس و منحنی شتاب خروجی آن را حذف می‌کند. افزایش وضوح می‌تواند فرکانس معادل را فراتر از رزونانس سوق دهد و به طور بالقوه باعث افت شتاب ناخواسته شود.

انیمیشن برخاستن یک موشک از پایین صفحه.
نمودار شکل موج ارتعاش ورودی.

شکل ۶. نمودار شکل موج شتاب خروجی برای ارتعاشی که پرتاب موشک را شبیه‌سازی می‌کند.

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