انیمیشن اسکرول در انگولار — از صفر تا صد

آخرین به‌روزرسانی: ۸ بهمن ۱۳۹۸
زمان مطالعه: ۷ دقیقه
انیمیشن اسکرول در انگولار

در زمان ایجاد اپلیکیشن‌های تک‌صفحه‌ای در برخی موارد لازم می‌شود که عناصری از صفحه را در زمان اسکرول شدن انیمیت کنیم. این کار مشابه آن چیزی است که در زمان استفاده از کتابخانه‌های محبوب انگولار مانند aos (+) در وب‌اپلیکیشن‌ها از آن برخوردار می‌شویم. اما نکته اینجا است که هیچ کدام از کتابخانه‌های موجود با انگولار و روش آن برای تحریک برنامه‌نویسی شده انیمیشن‌ها به خوبی تطبیق نمی‌یابند. بنابراین در این مقاله یک راه‌حل برای انیمیشن اسکرول در انگولار به نام wmAnimate طراحی می‌کنیم. در این مقاله فرض شده است که شما با فریمورک انگولار آشنا هستید و به طورخاص با انیمیشن‌های انگولار کار کرده‌اید.

انیمیشن اسکرول

صفحه اصلی

در این بخش یک صفحه اصلی که از کتابخانه aos استفاده می‌کند را مشاهده می‌کنید:

<section fxLayout="row" fxLayoutAlign="center center">

  <div fxFlex="50" wmAnimate="landing" speed="slow" aos>

    <h1 class="mat-display-1"><b>Get your app done right</b></h1>

    <p>Wizdm provides wonders to your singple page application</p>

    <button mat-raised-button color="accent" routerLink="/">Get started</button>

    <p class="mat-small">Join us and become part of the revolution of wizdm</p>

  </div>

  <img fxFlex="50" src="assets/blue-buddha.svg" />

</section>

در این کد بخش اصلی کار با استفاده از دایرکتیو wmAnimate انجام می‌یابد که به کانتینر div دستور می‌دهد تا انیمیشنی به نام loading را با سرعت slow اجرا کند و همزمان فلگ aos تنها فلگی است که عملاً به کامپوننت اعلام می‌کند باید در زمان اسکرول شدن ویو تحریک شود.

کامپوننت انیمیت

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

@Component({
 selector: '[wmAnimate]',
 template: '<ng-content></ng-content>',
 animations: $animations
})
export class AnimateComponent implements OnInit, OnDestroy {

  readonly timings = { slower: '3s', slow: '2s', normal: '1s', fast: '500ms', faster: '300ms' };
  
  constructor(private elm: ElementRef, private scroll: ScrollDispatcher, private zone: NgZone) {}

  /** Selects the animation to be played */
  @Input('wmAnimate') animate: wmAnimations;

  /** Speeds up or slows down the animation */
  @Input() speed: wmAnimateSpeed = 'normal';

  /** When true, triggers the animation on element scrolling in the viewport */
  @Input('aos') set enableAOS(value: boolean) { this.aos = coerceBooleanProperty(value); }
  public aos: boolean = false;

  /** Specifies the amout of visibility triggering AOS */
  @Input() threshold: number = 0.2;

  ngOnInit() { 

    // Triggers the animation based on the input flags
    this.animateTrigger(this.elm).subscribe( trigger => {
      // Triggers the animation to play or to idle
      this.trigger = trigger ? this.play : this.idle;
    });
  }
...
}

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

طرز کار کامپوننت

همچنان که اشاره کردیم، کامپوننت فوق چند انیمیشن داخلی پیاده‌سازی می‌کند که می‌توانیم از بین آن‌ها یکی را انتخاب کرده و دایرکتیو مربوطه را روی کانتینر اعمال کنیم. اگر نگاهی به آرایه animations$ بیندازیم، می‌بینیم که کامپوننت یک انیمیشن منفرد را پیاده‌سازی می‌کند که animate@ را با یک حالت idle تحریک می‌کند تا عملاً کانتینر را پیش از این که تحریک رخ بدهد پنهان سازد و چند «گذار» (Transition) نیز وجود دارند که ورود و خروج‌های ممکن را که می‌توان از میان آن‌ها انتخاب کرد را توصیف می‌کنند:

export const $animations = [

  trigger('animate', [

    state('idle', style({ opacity: 0 }) ),

    transition('* => landing', [
      style({
        transform: 'scale(1.2)',
        opacity: 0
      }), 
      animate('{{timing}} ease', style('*'))
    ], { params: { timing: '2s'}}),

    transition('* => pulse', [
      style('*'),
      animate('{{timing}} ease-in-out', 
        keyframes([
          style({ transform: 'scale(1)' }),
          style({ transform: 'scale(1.05)' }),
          style({ transform: 'scale(1)' })
        ])
      )], { params: { timing: '1s'}}
    ),

    transition('* => beat', [
      style('*'),
      animate('{{timing}} cubic-bezier(.8, -0.6, 0.2, 1.5)', 
        keyframes([
          style({ transform: 'scale(0.8)' }),
          style({ transform: 'scale(1.5)' }),
          style({ transform: 'scale(1)' })
        ])
      )], { params: { timing: '500ms'}}
    ),

    transition('* => heartBeat', [
      style('*'),
      animate('{{timing}} ease-in-out', 
        keyframes([
          style({ transform: 'scale(1)', offset: 0 }),
          style({ transform: 'scale(1.3)', offset: 0.14 }),
          style({ transform: 'scale(1)', offset: 0.28 }),
          style({ transform: 'scale(1.3)', offset: 0.42 }),
          style({ transform: 'scale(1)', offset: 0.70 })
        ])
      )], { params: { timing: '1s'}}
    ),

    transition('* => fadeIn', [
      style({ opacity: 0 }),
      animate('{{timing}} ease-in', style('*'))
    ], { params: { timing: '1s'}}),

    transition('* => fadeInRight', [
      style({ opacity: 0, transform: 'translateX(-20px)' }),
      animate('{{timing}} ease-in', style('*'))
    ], { params: { timing: '1s'}}),

    transition('* => fadeInLeft', [
      style({ opacity: 0, transform: 'translateX(20px)' }),
      animate('{{timing}} ease-in', style('*'))
    ], { params: { timing: '1s'}}),

    transition('* => fadeInUp', [
      style({ opacity: 0, transform: 'translateY(20px)' }),
      animate('{{timing}} ease-in', style('*'))
    ], { params: { timing: '1s'}}),

    transition('* => fadeInDown', [
      style({ opacity: 0, transform: 'translateY(-20px)' }),
      animate('{{timing}} ease-in', style('*'))
    ], { params: { timing: '1s'}}),

    transition('* => zoomIn', 
      animate('{{timing}} ease-in', 
        keyframes([
          style({ opacity: 0, transform: 'scale(0.3)' }),
          style({ opacity: 1, transform: 'scale(0.65)' }),
          style({ opacity: 1, transform: 'scale(1)' })
        ])
      ), { params: { timing: '1s'}}
    ),
    
    transition('* => bumpIn', [
      style({ transform: 'scale(0.5)', opacity: 0 }),
      animate("{{timing}} cubic-bezier(.8, -0.6, 0.2, 1.5)", 
        style({ transform: 'scale(1)', opacity: 1 }))
    ], { params: { timing: '500ms'}}),

    transition('fadeOut => void', [
      animate('{{timing}} ease-in', style({ opacity: 0 }))
    ]),

    transition('fadeOutRight => void', [
      animate('{{timing}} ease-in', style({ opacity: 0, transform: 'translateX(20px)' }))
    ], { params: { timing: '1s'}}),

    transition('fadeOutLeft => void', [
      animate('{{timing}} ease-in', style({ opacity: 0, transform: 'translateX(-20px)' }))
    ], { params: { timing: '1s'}}),

    transition('fadeOutDown => void', [
      animate('{{timing}} ease-in', style({ opacity: 0, transform: 'translateY(20px)' }))
    ], { params: { timing: '1s'}}),

    transition('fadeOutUp => void', [
      animate('{{timing}} ease-in', style({ opacity: 0, transform: 'translateY(-20px)' }))
    ], { params: { timing: '1s'}}),

    transition('zoomOut => void', 
      animate('{{timing}} ease-in', 
        keyframes([
          style({ opacity: 1, transform: 'scale(1)' }),
          style({ opacity: 0, transform: 'scale(0.3)' }),
          style({ opacity: 0, transform: 'scale(0.3)' })
        ])
      ), { params: { timing: '1s'}}
    ),
  ])
];

بنابراین ورودی wmAnimate به محض این که تحریک رخ بدهد، یک گذار را که قرار است کامپوننت اجرا کند انتخاب می‌کند. هر گذار یک مدت زمان‌بندی پیش‌فرض دارد و از این رو در صورتی که سرعتی از سوی ورودی speed تعیین نشده باشد، انیمیشن در سرعت پیش‌فرض خودش اجرا می‌شود. سرعت‌های ممکن به شرح زیر هستند:

  • Slower: زمان‌بندی 3 ثانیه‌ای
  • Slow: زمان‌بندی 3 ثانیه‌ای
  • Normal: زمان‌بندی 1 ثانیه‌ای
  • Fast: زمان‌بندی 500 میلی‌ثانیه‌ای
  • Faster: زمان‌بندی 300 میلی‌ثانیه‌ای

در نهایت سه روش برای تحریک انیمیشن وجود دارد:

  1. به صورت پیش‌فرض انیمیشن ورودی به محض این که کانتینر وارد شود اجرا خواهد شد و انیمیشن خروجی نیز به محض خروج کانتینر به اجرا درمی‌آید.
  2. به صورت اختیاری می‌توان انیمیشن را به صورت برنامه‌نویسی شده با تعیین ورودی replay روی هر مقداری که در زمان تبدیل به مقدار بولی به صوت true تبدیل می‌شود اجرا کرد. اساساً تعیین ورودی paused به صورت true از اجرای انیمیشن به صورت خودکار در نخستین بار جلوگیری می‌کند.
  3. در نهایت انیمیشن در زمان اسکرول با تعیین ورودی aos روی true فعال می‌شود. بدین ترتیب هر بار که کانتینر وارد ویو شود اجرا می‌شود و زمانی که از آن خارج شود در حالت idle قرار می‌گیرد. با این حال با تعیین ورودی once روی true از اجرای انیمیشن بیش از یک بار جلوگیری می‌کنیم.

زمانی که AOS فعال شود، انیمیشن در زمان ورود کانتینر به ویو تا حد 20% تحریک می‌شود. این مقدار را می‌توان با تعیین ورودی threshold روی یک مقدار متفاوت از 0.2 تغییر داد.

تحریک انیمیشن

تحریک انیمیشن در زمان مقداردهی اولیه کامپوننت درون قلاب چرخه عمری ()ngOnInit ساخته می‌شود:

ngOnInit() { 
  // Triggers the animation based on the input flags
  this.animateTrigger(this.elm).subscribe( trigger => {
    // Triggers the animation to play or to idle
    this.trigger = trigger ? this.play : this.idle;
  });
}

// Triggers the animation
private animateTrigger(elm: ElementRef<HTMLElement>): Observable<boolean> {

  return this.animateReplay().pipe( flatMap( trigger => this.aos ? this.animateOnScroll(elm) : of(trigger)) );
}
  
// Triggers the animation deferred
private animateReplay(): Observable<boolean> {

  return this.replay$.pipe( takeUntil(this.dispose$), delay(0), startWith(!this.paused) );
}

// Triggers the animation on scroll
private animateOnScroll(elm: ElementRef<HTMLElement>): Observable<boolean> {
...
}

تابع ()animateTrigger یک observable است که به یک مقدار بولی تبدیل می‌شود که در زمان true بودن آن انیمیشن پخش می‌شود و در حالت false انیمیشن به حالت idle می‌رود.

زمانی که ورودی aos روی true باشد، observable بازگشتی اقدام به استفاده از ScrollDispatcher از angular/cdk@ برای ردگیری رویدادهای اسکرول شدن می‌کند و ناحیه همپوشان بین عنصر کانتینر و نمای اسکرول شونده را محاسبه می‌کند تا بر این اساس انیمیشن را تحریک کند:

// Triggers the animation on scroll
private animateOnScroll(elm: ElementRef<HTMLElement>): Observable<boolean> {

  // Returns an AOS observable
  return this.scroll.ancestorScrolled(elm, 100).pipe(
    // Makes sure to dispose on destroy
    takeUntil(this.dispose$),
    // Starts with initial element visibility 
    startWith(!this.paused  && this.visibility >= this.threshold),
    // Maps the scrolling to the element visibility value
    map(() => this.visibility),
    // Applies an hysteresys, so, to trigger the animation on based on the treshold while off on full invisibility
    scan<number,boolean>((result, visiblility) => (visiblility >= this.threshold || (result ? visiblility > 0 : false))),
    // Distincts the resulting triggers 
    distinctUntilChanged(),
    // Stop taking the first on trigger when aosOnce is set
    takeWhile(trigger => !trigger || !this.once, true),
    // Run NEXT within the angular zone to trigger change detection back on
    flatMap(trigger => new Observable<boolean>(observer => this.zone.run(() => observer.next(trigger))))
  );
}

جزئیات AOS

در این بخش به بررسی کد AOS به صورت گام به گام می‌پردازیم.

  • تابع از یک observable بازگشتی از سوی متد ()ancestorScrolled از ScrollDispatcher آغاز می‌شود که در صورتی صادر می‌شود که هر یک از اجداد قابل اسکرول یک عنصر مورد اسکرول واقع شوند.
  • کد زیر تضمین می‌کند که همه observable-ها در زمان پایان کار حذف می‌شوند. بهتر است در زمان استفاده از چندین observable از این متد درون کامپوننت بهره بگیریم تا همه observable-ها را به‌یک‌باره درون قلاب چرخه عمری ()ngOnDestroy حذف کنیم. این روش بهتر از استفاده از چنین «اشتراک» (subscription) است:
takeUntil(this.dispose$)
  • کد زیر تضمین می‌کند که نخستین مقدار صادر شده با موقعیت‌های مختلف تطبیق پیدا می‌کند:
startWith(!this.paused && this.visibility >= this.threshold)
  • خط زیر موجب می‌شود که مقدار ارائه شده به «نسبت نمایانی» تبدیل شود یعنی یک مقدار عددی به دست می‌آید که میزان ناحیه‌ای از عنصر که در حال حاضر روی صفحه درون کانتینر اسکرول شونده دیده می‌شود را مشخص می‌کند. بدین ترتیب عدد 0 به معنی کاملاً پنهان و 1.0 به معنی کاملاً نمایان است:
map(() => this.visibility)
  • خط زیر انیمیشن را بر اساس مقدار threshold تحریک می‌کند، در حالی که حالت idle زمانی که کانتینر دوباره کاملاً پنهان شود بازیابی می‌شود:
scan((result, visiblility) => …)
  • خط زیر صدور مقادیر را صرفاً روی مقادیر متغیر محدود می‌سازد، بنابراین از صدور رویدادهای بسیار زیاد از سوی اپلیکیشن جلوگیری به عمل می‌آید:
distinctUntilChanged()
  • خط زیر تضمین می‌کند که وقتی once روی true قرار گرفته باشد، صدور مقادیر در نخستین بار تحریک معتبر متوقف می‌شود:
takeWhile(trigger =>!trigger ||!this.once, true)
  • در نهایت flatMap(trigger => …) به یک observable تبدیل می‌شود که در عمل درون ناحیه انگولار اجرا می‌شود و از این رو مکانیسم تشخیص حرکت انگولار را تحریک می‌کند چون ScrollDispatcher به صورت پیش‌فرض خارج از ناحیه انگولار فعالیت می‌کند تا از تأثیرات روی عملکرد جلوگیری شود.

سخن پایانی

در این مقاله یک کامپوننت به نام wmAnimate طراحی کردیم که با استفاده از آن می‌توانیم انیمیشن‌های در زمان اسکرول شدن صفحه در وب‌اپلیکیشن‌های انگولار اجرا کنیم. برای مشاهده کد کامل این کامپوننت به این ریپوی گیت‌هاب (+) مراجعه کنید.

اگر این نوشته برای شما مفید بوده است، آموزش‌ها و مطالب زیر نیز احتمالاً برای شما مفید خواهند بود:

==

بر اساس رای ۰ نفر
آیا این مطلب برای شما مفید بود؟
شما قبلا رای داده‌اید!
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
wizdm-genesys

نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *