Skip to content

Commit

Permalink
Merge pull request #2555 from exadel-inc/feat/esl-carousel-animation
Browse files Browse the repository at this point in the history
feat(esl-carousel): rework default renderer animation approach (now uses js animation)
  • Loading branch information
ala-n authored Aug 1, 2024
2 parents 249ed6e + c2dfae5 commit 0bd7a77
Show file tree
Hide file tree
Showing 9 changed files with 73 additions and 80 deletions.
2 changes: 1 addition & 1 deletion src/modules/esl-carousel/core/esl-carousel.events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface ESLCarouselSlideEventInit {
/** A list of indexes of slides that are active after the change */
indexesAfter: number[];
/** Direction of slide animation */
direction: ESLCarouselDirection;
direction: ESLCarouselDirection | null;
/** Auxiliary request attribute that represents object that initiates slide change */
activator?: any;
}
Expand Down
4 changes: 2 additions & 2 deletions src/modules/esl-carousel/core/esl-carousel.renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {memoize} from '../../esl-utils/decorators';
import {isEqual} from '../../esl-utils/misc/object';
import {SyntheticEventTarget} from '../../esl-utils/dom';
import {ESLCarouselSlideEvent} from './esl-carousel.events';
import {calcDirection, normalize, sequence} from './nav/esl-carousel.nav.utils';
import {normalize, sequence, indexToDirection} from './nav/esl-carousel.nav.utils';

import type {ESLCarousel, ESLCarouselActionParams} from './esl-carousel';
import type {ESLCarouselConfig, ESLCarouselDirection} from './nav/esl-carousel.nav.types';
Expand Down Expand Up @@ -155,7 +155,7 @@ export abstract class ESLCarouselRenderer implements ESLCarouselConfig {
}

if (event && typeof event === 'object') {
const direction = event.direction || calcDirection(related, current, this.size);
const direction = event.direction || indexToDirection(related, this.$carousel.state);
const details = {...event, direction, indexesBefore, indexesAfter};
this.$carousel.dispatchEvent(ESLCarouselSlideEvent.create('AFTER', details));
}
Expand Down
6 changes: 3 additions & 3 deletions src/modules/esl-carousel/core/esl-carousel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,9 +301,9 @@ export class ESLCarousel extends ESLBaseElement {
public goTo(target: HTMLElement | ESLCarouselSlideTarget, params: ESLCarouselActionParams = {}): Promise<void> {
if (target instanceof HTMLElement) return this.goTo(this.indexOf(target), params);
if (!this.renderer) return Promise.reject();
const {index, dir} = toIndex(target, this.state);
const direction = params.direction || dir || 'next';
return this.renderer.navigate(index, direction, params);
const nav = toIndex(target, this.state);
const direction = (this.loop ? params.direction : null) || nav.direction || 'next';
return this.renderer.navigate(nav.index, direction, params);
}

/** @returns slide by index (supports not normalized indexes) */
Expand Down
14 changes: 14 additions & 0 deletions src/modules/esl-carousel/core/nav/esl-carousel.nav.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,31 @@ export type ESLCarouselSlideTarget = string | ESLCarouselNavIndex | `slide:${ESL

/** Object describing static carousel configuration */
export type ESLCarouselStaticState = {
/** Total slide count */
size: number;
/** Visible slide count per view */
count: number;
/** Cyclic carousel rendering mode */
loop: boolean;
/** Vertical carousel rendering mode */
vertical: boolean;
};

export type ESLCarouselConfig = ESLCarouselStaticState & {
/** Renderer type name */
type: string;
};

/** Object describing carousel current configuration (contains active slide data) */
export type ESLCarouselState = ESLCarouselStaticState & {
/** First active slide index */
activeIndex: number;
};

/** Object describing carousel navigation target details */
export type ESLCarouselNavInfo = {
/** Target index */
index: number;
/** Direction to reach the index */
direction: ESLCarouselDirection | null;
};
45 changes: 23 additions & 22 deletions src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
ESLCarouselDirection,
ESLCarouselNavIndex,
ESLCarouselNavInfo,
ESLCarouselSlideTarget,
ESLCarouselState,
ESLCarouselStaticState
Expand Down Expand Up @@ -33,7 +34,7 @@ export function getDistance(from: number, direction: ESLCarouselDirection, {acti
}

/** @returns closest direction to move from slide `from` to slide `to` */
export function calcDirection(from: number, to: number, size: number): ESLCarouselDirection {
function calcDirection(from: number, to: number, size: number): ESLCarouselDirection {
const abs = Math.abs(from - to) % size;
if (to > from) {
return abs > size - abs ? 'prev' : 'next';
Expand Down Expand Up @@ -83,52 +84,52 @@ function splitTarget(target: string): {index: string, type: string} {
}

/** Parses index value defining its value and type (absolute|relative) */
function parseIndex(index: string | ESLCarouselNavIndex): {value: number, isRelative: boolean, dir?: ESLCarouselDirection} {
function parseIndex(index: string | ESLCarouselNavIndex): {value: number, isRelative: boolean, direction?: ESLCarouselDirection} {
if (typeof index === 'number') return {value: index, isRelative: false};
index = index.trim();
if (index === 'next') return {value: 1, isRelative: true, dir: 'next'};
if (index === 'prev') return {value: -1, isRelative: true, dir: 'prev'};
if (index[0] === '+') return {value: +index, isRelative: true, dir: 'next'};
if (index[0] === '-') return {value: +index, isRelative: true, dir: 'prev'};
if (index === 'next') return {value: 1, isRelative: true, direction: 'next'};
if (index === 'prev') return {value: -1, isRelative: true, direction: 'prev'};
if (index[0] === '+') return {value: +index, isRelative: true, direction: 'next'};
if (index[0] === '-') return {value: +index, isRelative: true, direction: 'prev'};
return {value: +index, isRelative: false};
}

/** @returns normalized numeric index from string with absolute or relative index */
function resolveSlideIndex(indexStr: string | ESLCarouselNavIndex, cfg: ESLCarouselState): {index: number, dir: ESLCarouselDirection | null} {
const {value, isRelative, dir} = parseIndex(indexStr);
function resolveSlideIndex(indexStr: string | ESLCarouselNavIndex, cfg: ESLCarouselState): ESLCarouselNavInfo {
const {value, isRelative, direction} = parseIndex(indexStr);
const target = value + (isRelative ? cfg.activeIndex : 0);
const index = normalizeIndex(target, cfg);
return {index, dir: dir || indexToDirection(index, cfg)};
return {index, direction: direction || indexToDirection(index, cfg)};
}

/** @returns normalized numeric index from string with absolute or relative group index */
function resolveGroupIndex(indexStr: string | ESLCarouselNavIndex, cfg: ESLCarouselState): {index: number, dir: ESLCarouselDirection | null} {
const {value, isRelative, dir} = parseIndex(indexStr);
function resolveGroupIndex(indexStr: string | ESLCarouselNavIndex, cfg: ESLCarouselState): ESLCarouselNavInfo {
const {value, isRelative, direction} = parseIndex(indexStr);
if (!isRelative) {
const index = groupToIndex(value, cfg.count, cfg.size);
return {index, dir: indexToDirection(index, cfg)};
return {index, direction: indexToDirection(index, cfg)};
}
// TODO: extend navigation boundaries
if (value === -1 && cfg.activeIndex < cfg.count && cfg.activeIndex > 0) {
return {index: 0, dir: dir || 'prev'};
return {index: 0, direction: direction || 'prev'};
}
if (value === 1 && normalize(cfg.activeIndex + cfg.count, cfg.size) > cfg.size - cfg.count) {
return {index: cfg.size - cfg.count, dir: dir || 'next'};
return {index: cfg.size - cfg.count, direction: direction || 'next'};
}
const index = normalize(cfg.activeIndex + value * cfg.count, cfg.size);
return {index, dir: dir || indexToDirection(index, cfg)};
return {index, direction: direction || indexToDirection(index, cfg)};
}

/** @returns normalized index from target definition and current state */
export function toIndex(target: ESLCarouselSlideTarget, cfg: ESLCarouselState): {index: number, dir: ESLCarouselDirection | null} {
export function toIndex(target: ESLCarouselSlideTarget, cfg: ESLCarouselState): ESLCarouselNavInfo {
if (typeof target === 'number') {
const index = normalizeIndex(target, cfg);
return {index, dir: indexToDirection(index, cfg)};
return {index, direction: indexToDirection(index, cfg)};
}
const {type, index} = splitTarget(target);
if (type === 'group') return resolveGroupIndex(index, cfg);
if (type === 'slide') return resolveSlideIndex(index, cfg);
return {index: cfg.activeIndex, dir: null};
return {index: cfg.activeIndex, direction: null};
}

/**
Expand All @@ -137,8 +138,8 @@ export function toIndex(target: ESLCarouselSlideTarget, cfg: ESLCarouselState):
*/
export function canNavigate(target: ESLCarouselSlideTarget, cfg: ESLCarouselState): boolean {
if (cfg.size <= cfg.count) return false;
const {dir, index} = toIndex(target, cfg);
if (!cfg.loop && index > cfg.activeIndex && dir === 'prev') return false;
if (!cfg.loop && index < cfg.activeIndex && dir === 'next') return false;
return !!dir && index !== cfg.activeIndex;
const {direction, index} = toIndex(target, cfg);
if (!cfg.loop && index > cfg.activeIndex && direction === 'prev') return false;
if (!cfg.loop && index < cfg.activeIndex && direction === 'next') return false;
return !!direction && index !== cfg.activeIndex;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@
max-height: 100%;
}

&[animating] [esl-carousel-slides] {
transition: transform 0.25s linear;
}

&.esl-carousel-horizontal [esl-carousel-slide] {
width: var(--esl-slide-size);
}
Expand Down
38 changes: 10 additions & 28 deletions src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {promisifyTransition} from '../../esl-utils/async';
import {normalize, normalizeIndex} from '../core/nav/esl-carousel.nav.utils';
import {ESLCarouselRenderer} from '../core/esl-carousel.renderer';

Expand Down Expand Up @@ -72,18 +71,19 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer {
if (!slide) return 0;
return this.vertical ? slide.offsetTop : slide.offsetLeft;
}

/** Sets scene offset */
protected setTransformOffset(offset: number): void {
this.$area.style.transform = `translate3d(${this.vertical ? `0px, ${offset}px` : `${offset}px, 0px`}, 0px)`;
}

/** @returns current slide area's transform offset */
protected getTransformOffset(): number {
// computed value is matrix(a, b, c, d, tx, ty)
const transform = getComputedStyle(this.$area).transform;
if (!transform || transform === 'none') return 0;
const position = this.vertical ? 5 : 4; // tx or ty position
return parseInt(transform.split(',')[position], 10);
/** Animate scene offset */
protected async animateTransformOffset(offset: number = -this.getOffset(this.currentIndex)): Promise<void> {
this.$carousel.$$attr('animating', true);
await this.$area.animate({
transform: [`translate3d(${this.vertical ? `0px, ${offset}px` : `${offset}px, 0px`}, 0px)`]
}, {duration: 250, easing: 'linear'}).finished;
this.$carousel.$$attr('animating', false);
}

/** Pre-processing animation action. */
Expand Down Expand Up @@ -118,17 +118,8 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer {
const offsetFrom = -this.getOffset(this.currentIndex);
this.setTransformOffset(offsetFrom);

// here is the final reflow before transition
const offsetTo = -this.getOffset(index);
// Allow animation and move to the target slide
this.$carousel.$$attr('animating', true);
this.setTransformOffset(offsetTo);
if (offsetTo !== offsetFrom) {
await promisifyTransition(this.$area, 'transform');
}

this.currentIndex = index;
this.$carousel.$$attr('animating', false);
await this.animateTransformOffset();
}

/** Handles the slides transition. */
Expand All @@ -151,7 +142,6 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer {
this.setTransformOffset(-stageOffset);

if (this.currentIndex !== this.$carousel.activeIndex) {
console.log('Apply active index %d (before %d)', this.currentIndex, this.$carousel.activeIndex);
this.setActive(this.currentIndex, {direction: offset < 0 ? 'next' : 'prev'});
}
}
Expand All @@ -167,15 +157,7 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer {
const index = from + count * this.INDEX_MOVE_MULTIPLIER * (offset < 0 ? 1 : -1);

this.currentIndex = normalizeIndex(index, this);

// Hm ... that's what actually happens on slide step
this.$carousel.$$attr('animating', true);
const stageOffset = -this.getOffset(this.currentIndex);
this.setTransformOffset(stageOffset);
if (stageOffset !== this.getTransformOffset()) {
await promisifyTransition(this.$area, 'transform');
}
this.$carousel.$$attr('animating', false);
await this.animateTransformOffset();

this.reorder();
this.setTransformOffset(-this.getOffset(this.currentIndex));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@
grid-auto-flow: row;
}

&[animating] [esl-carousel-slides] {
transition: transform 0.25s linear;
}

/* stylelint-disable-next-line */
&.esl-carousel-horizontal :is([esl-carousel-slide], [esl-carousel-fake-slide]) {
width: var(--esl-slide-size);
Expand Down
36 changes: 20 additions & 16 deletions src/modules/esl-carousel/test/core/esl-carousel.nav.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {
normalize,
calcDirection,
groupToIndex,
indexToGroup,
indexToDirection,
toIndex
} from '../../core/nav/esl-carousel.nav.utils';

Expand Down Expand Up @@ -30,21 +30,6 @@ describe('ESLCarousel: Nav Utils', () => {
);
});

describe('calcDirection', () => {
test.each([
// [from index, to index, size, result]
[0, 1, 5, 'next'],
[1, 3, 5, 'next'],
[3, 2, 5, 'prev'],
[1, 4, 5, 'prev'],
[1, 7, 5, 'next'],
[7, 1, 5, 'prev']
])(
'(from = %d, to = %d, size = %d) => %s',
(from: number, to: number, count: number, result: ESLCarouselDirection) => expect(calcDirection(from, to, count)).toBe(result)
);
});

describe('groupToIndex', () => {
test.each([
// [group index, active count, size, result]
Expand Down Expand Up @@ -158,6 +143,25 @@ describe('ESLCarousel: Nav Utils', () => {
);
});

describe('indexToDirection', () => {
test.each([
// [to, {activeIndex, size, loop}, result]
[1, {activeIndex: 0, size: 5, loop: false}, 'next'],
[4, {activeIndex: 0, size: 5, loop: false}, 'next'],
[4, {activeIndex: 3, size: 5, loop: false}, 'next'],
[0, {activeIndex: 1, size: 5, loop: false}, 'prev'],
[0, {activeIndex: 4, size: 5, loop: false}, 'prev'],

[1, {activeIndex: 0, size: 5, loop: true}, 'next'],
[3, {activeIndex: 1, size: 5, loop: true}, 'next'],
[2, {activeIndex: 3, size: 5, loop: true}, 'prev'],
[4, {activeIndex: 1, size: 5, loop: true}, 'prev']
])(
'(to = %d, state = %p) => %s',
(to: number, state: ESLCarouselState, result: ESLCarouselDirection) => expect(indexToDirection(to, state)).toBe(result)
);
});

describe('toIndex', () => {
describe('numeric input', () => {
test.each([
Expand Down

0 comments on commit 0bd7a77

Please sign in to comment.