إنشاء تأثيرات ملموسة مخصَّصة

تتناول هذه الصفحة أمثلة على كيفية استخدام واجهات برمجة تطبيقات مختلفة خاصة باللمس لإنشاء تأثيرات مخصّصة تتجاوز أشكال الموجات الاهتزازية العادية في تطبيق Android.

تتضمّن هذه الصفحة الأمثلة التالية:

للاطّلاع على أمثلة إضافية، راجِع إضافة ملاحظات حسية إلى الأحداث، واحرص دائمًا على اتّباع مبادئ تصميم الملاحظات الحسية.

استخدام طرق احتياطية للتعامل مع توافق الجهاز

عند تنفيذ أي تأثير مخصّص، يُرجى مراعاة ما يلي:

  • إمكانات الجهاز المطلوبة لتطبيق التأثير
  • ما يجب فعله عندما لا يكون الجهاز قادرًا على تشغيل التأثير

يقدّم مرجع واجهة برمجة التطبيقات Android Haptics تفاصيل حول كيفية التحقّق من توفّر الدعم للمكوّنات المعنية باللمس، حتى يتمكّن تطبيقك من تقديم تجربة شاملة متسقة.

استنادًا إلى حالة الاستخدام، قد تحتاج إلى إيقاف المؤثرات المخصّصة أو توفير مؤثرات مخصّصة بديلة استنادًا إلى إمكانات محتملة مختلفة.

خطِّط لفئات إمكانات الأجهزة العالية المستوى التالية:

  • إذا كنت تستخدم عناصر أساسية تعمل باللمس: الأجهزة التي تتوافق مع هذه العناصر الأساسية التي تحتاج إليها التأثيرات المخصّصة. (راجِع القسم التالي لمعرفة تفاصيل حول الأنواع الأساسية).

  • الأجهزة التي تتضمّن التحكّم في السعة

  • الأجهزة التي تتوافق مع الاهتزاز الأساسي (تشغيل/إيقاف)، أي الأجهزة التي لا تتضمّن إمكانية التحكّم في السعة

إذا كان خيار التأثير اللمسي في تطبيقك يراعي هذه الفئات، يجب أن تظل تجربة المستخدم اللمسية قابلة للتوقّع على أي جهاز فردي.

استخدام عناصر اللمس الأساسية

يتضمّن نظام التشغيل Android العديد من العناصر الأساسية التي تختلف في كل من السعة والتردد. يمكنك استخدام أحد العناصر الأساسية بمفرده أو استخدام عناصر أساسية متعددة معًا لتحقيق تأثيرات لمسية غنية.

  • استخدِم فواصل زمنية تبلغ 50 مللي ثانية أو أكثر لإنشاء فجوات واضحة بين عنصرَين أساسيَين، مع مراعاة مدة العنصر الأساسي إذا أمكن.
  • استخدِم مقاييس تختلف بنسبة ‏1.4 أو أكثر حتى يسهل إدراك الفرق في الشدة.
  • استخدِم المقاييس 0.5 و0.7 و1.0 لإنشاء إصدارات منخفضة ومتوسطة وعالية الكثافة من عنصر أساسي.

إنشاء أنماط اهتزاز مخصّصة

غالبًا ما تُستخدم أنماط الاهتزاز في تقنية اللمس التي تجذب الانتباه، مثل الإشعارات ونغمات الرنين. يمكن لخدمة Vibrator تشغيل أنماط اهتزاز طويلة تغيّر سعة الاهتزاز بمرور الوقت. وتُسمّى هذه التأثيرات أشكال الموجات.

يمكن عادةً إدراك تأثيرات الأشكال الموجية، ولكن قد تؤدي الاهتزازات الطويلة المفاجئة إلى إزعاج المستخدم إذا تم تشغيلها في بيئة هادئة. قد يؤدي الانتقال إلى مستوى صوت مستهدف بسرعة كبيرة أيضًا إلى حدوث ضوضاء طنين مسموعة. صمِّم أنماط الموجات الصوتية لتسوية انتقالات السعة من أجل إنشاء تأثيرات زيادة وخفض مستوى الصوت تدريجيًا.

أمثلة على أنماط الاهتزاز

تقدّم الأقسام التالية عدة أمثلة على أنماط الاهتزاز:

نمط زيادة الاستخدام

يتم تمثيل الأشكال الموجية على النحو التالي: VibrationEffect مع ثلاث مَعلمات:

  1. التوقيتات: مصفوفة من المدد بالمللي ثانية لكل جزء من شكل الموجة
  2. السعات: سعة الاهتزاز المطلوبة لكل مدة زمنية محدّدة في الوسيط الأول، ويتم تمثيلها بقيمة عدد صحيح تتراوح بين 0 و255، حيث يمثّل 0 حالة "إيقاف" المهتز و255 يمثّل الحد الأقصى لسعة الجهاز.
  3. فهرس التكرار: الفهرس في المصفوفة المحدّدة في الوسيطة الأولى لبدء تكرار الشكل الموجي، أو -1 إذا كان يجب تشغيل النمط مرة واحدة فقط.

في ما يلي مثال على شكل موجي ينبض مرتين مع توقّف مؤقت لمدة 350 ملي ثانية بين النبضات. النبضة الأولى هي زيادة سلسة في السعة إلى الحد الأقصى، أما النبضة الثانية فهي زيادة سريعة في السعة إلى الحد الأقصى. يتم تحديد التوقف في النهاية من خلال قيمة فهرس التكرار السالبة.

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

نمط متكرّر

يمكن أيضًا تشغيل الأشكال الموجية بشكل متكرر إلى أن يتم إلغاؤها. لإنشاء شكل موجي متكرّر، عليك ضبط المَعلمة repeat على قيمة غير سالبة. عند تشغيل شكل موجي متكرّر، يستمر الاهتزاز إلى أن يتم إلغاؤه بشكل صريح في الخدمة:

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

وهذا مفيد جدًا للأحداث المتقطّعة التي تتطلّب اتّخاذ المستخدم إجراءً لتأكيدها. وتشمل أمثلة هذه الأحداث المكالمات الهاتفية الواردة والمنبّهات التي تم تفعيلها.

النمط مع الإجراء الاحتياطي

التحكّم في سعة الاهتزاز هو إمكانية تعتمد على الأجهزة. سيؤدي تشغيل شكل موجي على جهاز منخفض المواصفات بدون هذه الإمكانية إلى اهتزاز الجهاز بأقصى سعة لكل إدخال موجب في مصفوفة السعة. إذا كان تطبيقك يحتاج إلى استيعاب هذه الأجهزة، استخدِم نمطًا لا يؤدي إلى إصدار صوت طنين عند تشغيله في تلك الحالة، أو صمِّم نمطًا أبسط لتشغيل/إيقاف يمكن تشغيله كبديل.

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

إنشاء مقطوعات اهتزاز

يعرض هذا القسم طرقًا لدمج الاهتزازات في مؤثرات مخصّصة أطول وأكثر تعقيدًا، ويتجاوز ذلك لاستكشاف ردود الفعل اللمسية الغنية باستخدام إمكانات الأجهزة الأكثر تقدّمًا. يمكنك استخدام مجموعات من المؤثرات التي تختلف في السعة والتردد لإنشاء مؤثرات لمسية أكثر تعقيدًا على الأجهزة التي تحتوي على مشغّلات لمسية ذات نطاق ترددي أوسع.

توضّح عملية إنشاء أنماط اهتزاز مخصّصة، التي تم وصفها سابقًا في هذه الصفحة، كيفية التحكّم في سعة الاهتزاز لإنشاء تأثيرات سلسة من خلال زيادة الاهتزاز تدريجيًا ثم خفضه. تعمل ميزة "اللمس الغني" على تحسين هذا المفهوم من خلال استكشاف نطاق التردد الأوسع لمهزّاز الجهاز من أجل جعل التأثير أكثر سلاسة. تكون هذه الموجات الصوتية فعّالة بشكل خاص في إنشاء تأثير تصاعدي أو تنازلي.

يتم تنفيذ العناصر الأساسية الخاصة بالتركيب، والموضّحة سابقًا في هذه الصفحة، من قِبل الشركة المصنّعة للجهاز. وتصدر هذه المحركات اهتزازًا واضحًا وقصيرًا وممتعًا يتوافق مع مبادئ اللمس للحصول على تجربة لمس واضحة. لمزيد من التفاصيل حول هذه الإمكانات وطريقة عملها، يمكنك الاطّلاع على مقدمة حول مشغّلات الاهتزاز.

لا يوفّر Android بدائل للمركّبات التي تحتوي على عناصر أساسية غير متوافقة. لذلك، اتّبِع الخطوات التالية:

  1. قبل تفعيل ميزة "اللمسات المتقدّمة"، تأكَّد من أنّ الجهاز المعنيّ يتوافق مع جميع العناصر الأساسية التي تستخدمها.

  2. عطِّل مجموعة التجارب المتّسقة غير المتوافقة، وليس فقط التأثيرات التي لا تتضمّن عنصرًا أساسيًا.

يمكنك الاطّلاع على مزيد من المعلومات حول كيفية التحقّق من توافق الجهاز في الأقسام التالية.

إنشاء تأثيرات اهتزاز مركّبة

يمكنك إنشاء تأثيرات اهتزاز مركّبة باستخدام VibrationEffect.Composition. في ما يلي مثال على تأثير ارتفاع تدريجي يلي ذلك تأثير نقرة حادة:

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

يتم إنشاء مقطوعة موسيقية من خلال إضافة عناصر أساسية ليتم تشغيلها بالتسلسل. كل عنصر أساسي قابل للتوسيع أيضًا، لذا يمكنك التحكّم في سعة الاهتزاز الناتجة عن كل عنصر. يتم تحديد المقياس كقيمة بين 0 و1، حيث يرتبط الرقم 0 في الواقع بالحد الأدنى من السعة التي يمكن للمستخدم الشعور بها (بصعوبة).

إنشاء صيغ مختلفة في عناصر الاهتزاز الأساسية

إذا أردت إنشاء نسخة ضعيفة ونسخة قوية من العنصر الأساسي نفسه، أنشئ نسب قوة تبلغ 1.4 أو أكثر، حتى يمكن إدراك الفرق في الشدة بسهولة. لا تحاول إنشاء أكثر من ثلاثة مستويات شدة للعنصر الأساسي نفسه، لأنّها ليست متميزة إدراكيًا. على سبيل المثال، استخدِم مقاييس 0.5 و0.7 و1.0 لإنشاء إصدارات منخفضة ومتوسطة وعالية الكثافة من عنصر أساسي.

إضافة فجوات بين عناصر الاهتزاز الأساسية

يمكن أن تحدّد التركيبة أيضًا فترات تأخير يتم إضافتها بين العناصر الأساسية المتتالية. يتم التعبير عن هذا التأخير بالملي ثانية منذ نهاية العنصر الأساسي السابق. بشكل عام، لا يمكن رصد فجوة تتراوح بين 5 و10 مللي ثانية بين عنصرين أساسيين. استخدِم فجوة تبلغ 50 ملي ثانية أو أكثر إذا أردت إنشاء فجوة يمكن تمييزها بين عنصرَين أساسيَّين. في ما يلي مثال على تركيبة تتضمّن تأخيرات:

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

التحقّق من العناصر الأساسية المتوافقة

يمكن استخدام واجهات برمجة التطبيقات التالية للتحقّق من توافق الجهاز مع عناصر أساسية معيّنة:

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

يمكنك أيضًا التحقّق من عناصر متعددة ثم تحديد العناصر التي تريد إنشاءها استنادًا إلى مستوى توافق الجهاز:

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

أمثلة على تركيبات الاهتزاز

تقدّم الأقسام التالية عدة أمثلة على تركيبات الاهتزاز، مأخوذة من تطبيق العيّنة الخاص بميزة "اللمس" على GitHub.

المقاومة (مع عدد قليل من العلامات)

يمكنك التحكّم في سعة الاهتزاز الأساسي لنقل ملاحظات وآراء مفيدة بشأن إجراء قيد التنفيذ. يمكن استخدام قيم المقياس المتقاربة لإنشاء تأثير تصاعدي سلس لعنصر أساسي. يمكن أيضًا ضبط التأخير بين العناصر الأساسية المتتالية بشكل ديناميكي استنادًا إلى تفاعل المستخدم. يتضح ذلك في المثال التالي الخاص بصورة متحركة للعرض يتم التحكّم فيها من خلال إيماءة سحب ويتم تحسينها باستخدام ردود فعل لمسية.

صورة متحركة لدائرة يتم سحبها للأسفل
رسم بياني للشكل الموجي للاهتزاز الناتج عن الإدخال

الشكل 1: يمثّل هذا الشكل الموجي تسارع الاهتزاز الناتج على الجهاز.

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

التوسّع (مع الارتفاع والانخفاض)

هناك عنصران أساسيان لزيادة مستوى الاهتزاز المحسوس: PRIMITIVE_QUICK_RISE وPRIMITIVE_SLOW_RISE. ويحققان الهدف نفسه، ولكن بمدد مختلفة. لا يتوفّر سوى عنصر أساسي واحد لخفض السرعة، وهو PRIMITIVE_QUICK_FALL. تعمل هذه العناصر الأساسية بشكل أفضل معًا لإنشاء مقطع موجي يزداد شدةً ثم ينخفض. ويمكنك محاذاة العناصر الأساسية التي تم تغيير حجمها لمنع حدوث قفزات مفاجئة في السعة بينها، ما يساعد أيضًا في إطالة مدة التأثير الإجمالية. من الناحية الإدراكية، يلاحظ الأشخاص دائمًا الجزء الصاعد أكثر من الجزء الهابط، لذا يمكن استخدام الجزء الصاعد بشكل أقصر من الجزء الهابط للتركيز على الجزء الهابط.

في ما يلي مثال على تطبيق هذه التركيبة لتوسيع دائرة وتصغيرها. يمكن أن يعزّز تأثير الارتفاع الشعور بالتوسّع أثناء الصورة المتحركة. يساعد الجمع بين تأثيرَي الارتفاع والانخفاض في التأكيد على الانهيار في نهاية الصورة المتحركة.

صورة متحركة لدائرة تتوسّع
رسم بياني للشكل الموجي للاهتزاز الناتج عن الإدخال

الشكل 2: يمثّل هذا الشكل الموجي تسارع الاهتزاز الناتج على الجهاز.

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

التأرجح (مع الدوران)

من مبادئ اللمس الأساسية إبهار المستخدمين. من الطرق المسلّية لإضافة تأثير اهتزاز لطيف وغير متوقّع استخدام PRIMITIVE_SPIN. تكون هذه العملية الأساسية أكثر فعالية عند استدعائها أكثر من مرة. يمكن أن يؤدي ربط عمليات تدوير متعددة إلى إنشاء تأثير متذبذب وغير ثابت، ويمكن تعزيز هذا التأثير بشكل أكبر من خلال تطبيق تغيير حجم عشوائي إلى حد ما على كل شكل أساسي. يمكنك أيضًا تجربة الفاصل الزمني بين العناصر الأساسية المتتالية في عملية الدوران. يؤدي تدويران بدون أي فجوة (0 مللي ثانية بينهما) إلى إنشاء إحساس قوي بالدوران. تؤدي زيادة الفجوة بين الدورات من 10 إلى 50 مللي ثانية إلى إحساس أقل بالدوران، ويمكن استخدامها لمطابقة مدة الفيديو أو الرسوم المتحركة.

لا تستخدِم فجوة تزيد مدتها عن 100 مللي ثانية، لأنّ عمليات التدوير المتتالية لن تتكامل بشكل جيد وستبدو كتأثيرات فردية.

في ما يلي مثال على شكل مرن يرتدّ بعد سحبه إلى الأسفل ثم تركه. يتم تحسين الصورة المتحركة باستخدام تأثيرَين للدوران، ويتم تشغيلهما بشدّة متفاوتة تتناسب مع مقدار الإزاحة الناتجة عن الارتداد.

صورة متحركة لشكل مرن يرتدّ
رسم بياني للشكل الموجي للاهتزاز الناتج

الشكل 3: يمثّل هذا الشكل الموجي تسارع الاهتزاز الناتج على الجهاز.

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

الارتداد (مع أصوات خافتة)

من التطبيقات المتقدّمة الأخرى للتأثيرات الاهتزازية محاكاة التفاعلات المادية. يمكن أن يؤدي استخدام PRIMITIVE_THUD إلى إنشاء تأثير قوي ومؤثر، ويمكن دمجه مع تصور لتأثير ما، في فيديو أو صورة متحركة مثلاً، لتحسين التجربة بشكل عام.

في ما يلي مثال على صورة متحركة لسقوط كرة تم تحسينها باستخدام تأثير صوت ارتطام يتم تشغيله في كل مرة ترتد فيها الكرة عن أسفل الشاشة:

صورة متحرّكة لكرة سقطت وترتد عن أسفل الشاشة
رسم بياني للشكل الموجي للاهتزاز الناتج عن الإدخال

الشكل 4. يمثّل هذا الشكل الموجي تسارع الاهتزاز الناتج على الجهاز.

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

شكل موجي للاهتزاز مع أشكال

تتيح لك عملية إنشاء أنماط اهتزاز مخصّصة التحكّم في سعة الاهتزاز لإنشاء تأثيرات سلسة من خلال زيادة الاهتزاز تدريجيًا ثم خفضه. يوضّح هذا القسم كيفية إنشاء تأثيرات لمسية ديناميكية باستخدام أشكال موجية تتيح التحكّم الدقيق في سعة الاهتزاز وتردده بمرور الوقت. يتيح لك ذلك تصميم تجارب لمسية أكثر ثراءً ودقةً.

بدءًا من Android 16 (المستوى 36 لواجهة برمجة التطبيقات)، يوفّر النظام واجهات برمجة التطبيقات التالية لإنشاء مغلف شكل موجة اهتزاز من خلال تحديد سلسلة من نقاط التحكّم:

  • BasicEnvelopeBuilder: طريقة سهلة لإنشاء تأثيرات لمسية متوافقة مع جميع الأجهزة.
  • WaveformEnvelopeBuilder: طريقة أكثر تقدّمًا لإنشاء مؤثرات لمسية، تتطلّب معرفة بأجهزة اللمس.

لا يوفّر نظام التشغيل Android بدائل لتأثيرات المغلف. إذا كنت بحاجة إلى هذا الدعم، يُرجى إكمال الخطوات التالية:

  1. تحقَّق مما إذا كان جهاز معيّن يتوافق مع تأثيرات المغلف باستخدام Vibrator.areEnvelopeEffectsSupported().
  2. أوقِف مجموعة التجارب المتّسقة غير المتوافقة، أو استخدِم أنماط اهتزاز مخصّصة أو مقطوعات كبدائل احتياطية.

لإنشاء المزيد من المؤثرات الأساسية للأصوات المغلفة، استخدِم BasicEnvelopeBuilder مع المَعلمات التالية:

  • قيمة الشدة في النطاق \( [0, 1] \)، والتي تمثّل قوة الاهتزاز المحسوسة. على سبيل المثال، يتم اعتبار القيمة \( 0.5 \) نصف الحد الأقصى العالمي للشدة التي يمكن أن يحققها الجهاز.
  • قيمة الحدة في النطاق \( [0, 1] \)، والتي تمثّل حدة الاهتزاز وتؤدي القيم المنخفضة إلى اهتزازات أكثر سلاسة، بينما تؤدي القيم المرتفعة إلى إحساس أكثر حدة.

  • قيمة المدة، وهي تمثّل الوقت المستغرَق بالملّي ثانية للانتقال من آخر نقطة تحكّم، أي زوج من الشدّة والحدة، إلى النقطة الجديدة.

في ما يلي مثال على شكل موجي يزيد من شدة الاهتزاز من مستوى منخفض إلى مستوى مرتفع، وبأقصى قوة، خلال 500 مللي ثانية، ثم يعود إلى مستوى\( 0 \) (إيقاف) خلال 100 مللي ثانية.

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

إذا كان لديك معرفة أكثر تقدّمًا بشأن اللمسات، يمكنك تحديد تأثيرات الغلاف باستخدام WaveformEnvelopeBuilder. عند استخدام هذا العنصر، يمكنك الوصول إلى ربط التردد بالتسارع الناتج (FOAM) من خلال VibratorFrequencyProfile.

  • قيمة السعة في النطاق \( [0, 1] \)، والتي تمثّل قوة الاهتزاز التي يمكن تحقيقها عند تردد معيّن، كما يحدّدها FOAM للجهاز. على سبيل المثال، تؤدي القيمة \( 0.5 \) إلى توليد نصف الحد الأقصى لتسارع الإخراج الذي يمكن تحقيقه عند التردد المحدّد.
  • قيمة التردد، ويتم تحديدها بالهرتز

  • قيمة المدة، وهي تمثّل الوقت المستغرَق بالملّي ثانية للانتقال من نقطة التحكّم الأخيرة إلى النقطة الجديدة

يعرض الرمز التالي مثالاً على شكل موجي يحدّد تأثير اهتزاز لمدة 400 مللي ثانية. يبدأ ذلك بزيادة تدريجية في السعة بمقدار 50 مللي ثانية، من إيقاف التشغيل إلى التشغيل الكامل، بتردد ثابت يبلغ 60 هرتز. بعد ذلك، يزداد التردد تدريجيًا إلى 120 هرتز خلال الـ 100 مللي ثانية التالية ويظل عند هذا المستوى لمدة 200 مللي ثانية. وأخيرًا، تنخفض السعة تدريجيًا إلى \( 0 \)، ويعود التردد إلى 60 هرتز خلال آخر 50 مللي ثانية:

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

تقدّم الأقسام التالية عدة أمثلة على أشكال موجية للاهتزاز مع أغلِفة.

Bouncing spring

يستخدم نموذج سابق PRIMITIVE_THUD لمحاكاة تفاعلات الارتداد المادي. توفّر واجهة برمجة التطبيقات الأساسية الخاصة بالمغلف تحكّمًا أدق بكثير، ما يتيح لك تخصيص شدة الاهتزاز وحدّته بدقة. ويؤدي ذلك إلى توفير ملاحظات لمسية تتوافق بشكل أكثر دقة مع الأحداث المتحركة.

في ما يلي مثال على زنبرك يسقط بحرية مع تحسين الصورة المتحركة باستخدام تأثير غلاف أساسي يتم تشغيله في كل مرة يرتد فيها الزنبرك عن أسفل الشاشة:

صورة متحركة لزنبرك يسقط ويرتدّ من أسفل الشاشة
رسم بياني للشكل الموجي للاهتزاز الناتج عن الإدخال

الشكل 5. رسم بياني لموجة تسارع الإخراج الخاصة باهتزاز يحاكي نابضًا مرنًا

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

لحظة إطلاق صاروخ

يوضّح نموذج سابق كيفية استخدام واجهة برمجة التطبيقات الأساسية الخاصة بالمغلف لمحاكاة رد فعل نابض مرن. تتيح WaveformEnvelopeBuilder إمكانية التحكم الدقيق في النطاق الكامل لتردد الجهاز، ما يتيح إنشاء تأثيرات لمسية مخصّصة للغاية. ومن خلال الجمع بين هذه البيانات وبيانات FOAM، يمكنك تخصيص الاهتزازات لتناسب إمكانات ترددية معيّنة.

في ما يلي مثال يوضّح محاكاة لإطلاق صاروخ باستخدام نمط اهتزاز ديناميكي. يتراوح التأثير من الحد الأدنى لناتج التسارع المسموح به، أي 0.1 G، إلى التردد الرنيني، مع الحفاظ دائمًا على إدخال السعة بنسبة %10. يسمح ذلك للتأثير بأن يبدأ بنتيجة قوية إلى حد ما ويزيد من الشدة والحدة المحسوسة، حتى لو كان السعة المحركة هي نفسها. عند الوصول إلى حالة الرنين، ينخفض تردد التأثير إلى الحد الأدنى، ما يُدرَك على أنّه انخفاض في الشدة والحدة. يؤدي ذلك إلى الشعور بمقاومة أولية يتبعها تحرّر، ما يحاكي عملية الإطلاق إلى الفضاء.

لا يمكن تحقيق هذا التأثير باستخدام واجهة برمجة التطبيقات الأساسية الخاصة بالمغلف، لأنّها تجرّد المعلومات الخاصة بالجهاز بشأن التردد الرنيني ومنحنى تسارع الإخراج. قد تؤدي زيادة الحدة إلى تجاوز التردد المكافئ للرنين، ما قد يتسبب في انخفاض غير مقصود في التسارع.

صورة متحركة لصاروخ ينطلق من أسفل الشاشة
رسم بياني للشكل الموجي للاهتزاز الناتج عن الإدخال

الشكل 6. رسم بياني لموجة تسارع ناتجة عن اهتزاز يحاكي عملية إطلاق صاروخ

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