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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

يتضمّن 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 // Do not 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; // Do not 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، ويرتبط الصفر في الواقع بحد أدنى من السعة التي يمكن للمستخدم (بالكاد) أن يشعر فيها بهذه القيمة الأساسية.

إذا أردت إنشاء نسخة ضعيفة وقوية من الشكل الأساسي نفسه، يُنصح بأن تختلف المقاييس بنسبة 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);

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

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

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

الشكل 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. تعمل هذه الأشكال الأساسية معًا بشكل أفضل لإنشاء جزء من شكل الموجة يزداد فيه الكثافة ثم ينطفئ. يمكنك محاذاة الأشكال الأساسية الموسّعة لمنع القفزات المفاجئة في amplitude بينها، ما يعمل أيضًا بشكل جيد لإطالة مدة أثر التأثير بشكل عام. من الناحية الإدراكية، يلاحظ الأشخاص دائمًا الجزء الصاعد أكثر من الجزء الهابط، لذا يمكن استخدام جعل الجزء الصاعد أقصر من الجزء الهابط لتحويل التركيز نحو الجزء الهابط.

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

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

الشكل 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 [-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 [-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;
          }
        }
      });
  }
}