DEV Community

HarmonyOS
HarmonyOS

Posted on

Visible-Edge Card Carousel with Swiper (ArkUI) — prevMargin/nextMargin + Scale Transition

Read the original article:Visible-Edge Card Carousel with Swiper (ArkUI) — prevMargin/nextMargin + Scale Transition

Visible-Edge Card Carousel with Swiper (ArkUI) — prevMargin/nextMargin + Scale Transition

Requirement Description

Implement a Swiper that shows a peek of the previous/next items on the current page and applies a scale animation during swipe—i.e., a card-carousel effect.

Background Knowledge

Event order: onGestureSwipeonAnimationStartonChangeonAnimationEnd.

Implementation Steps

Approach A — Event Combo (gesture + animation lifecycle)

  1. Set prevMargin/nextMargin so neighbors are partially visible.
  2. Track drag distance in onGestureSwipe and compute current/prev/next scales.
  3. In onAnimationStart, snap scales to MAX for target and MIN for neighbors.
  4. Update currentIndex in onChange.
  5. Reset helpers in onAnimationEnd.

Approach B — customContentTransition (single place)

  1. Provide a data source and initial scaleArray.
  2. In onChange, mark the selected page as MAX and neighbors as MIN.
  3. In customContentTransition.transition(proxy), compute per-frame current/next/prev scale from proxy.selectedIndex/index/position/mainAxisLength.

Code Snippet / Configuration

Event Combo (condensed)

const MAX_SCALE = 0.7;
const MIN_SCALE = 0.5;
const DRAGGING_MAX_DISTANCE = 1000;
const PAGE_DURATION = 100;
const SWIPER_DURATION = 500;
const CARD_COUNT = 6;

@Entry
@Component
struct CardCarousel {
  private ctrl: SwiperController = new SwiperController();
  @State currentIndex: number = 0;
  @State scaleArray: number[] = new Array(CARD_COUNT).fill(MIN_SCALE);
  private colorArray: Color[] = [Color.Yellow, Color.Blue, Color.Green, Color.Red, Color.Gray, Color.Orange];
  private startSwiperOffset: number = 0;

  aboutToAppear() {
    this.scaleArray[0] = MAX_SCALE;
  }

  private getNextIndex(index: number): number {
    return (index + 1) % CARD_COUNT;
  }

  private getPrevIndex(index: number): number {
    return (index - 1 + CARD_COUNT) % CARD_COUNT;
  }

  private onGestureSwipe(index: number, e: SwiperAnimationEvent) {
    if (this.startSwiperOffset === 0) {
      this.startSwiperOffset = e.currentOffset;
    }

    const distance = Math.abs(this.startSwiperOffset - e.currentOffset);
    const delta = Math.min(distance / DRAGGING_MAX_DISTANCE, MAX_SCALE - MIN_SCALE);
    const nextIndex = this.getNextIndex(index);
    const prevIndex = this.getPrevIndex(index);

    this.scaleArray[index] = MAX_SCALE - delta;

    if (e.currentOffset < this.startSwiperOffset) {
      this.scaleArray[nextIndex] = MIN_SCALE + delta;
      this.scaleArray[prevIndex] = MIN_SCALE;
    } else {
      this.scaleArray[prevIndex] = MIN_SCALE + delta;
      this.scaleArray[nextIndex] = MIN_SCALE;
    }
  }

  private onAnimationStart(_: number, targetIndex: number) {
    this.scaleArray = this.scaleArray.map((_, i) => i === targetIndex ? MAX_SCALE : MIN_SCALE);
  }

  build() {
    Column() {
      Swiper(this.ctrl) {
        ForEach(this.colorArray, (color: Color, index: number) => {
          Column()
            .width('100%')
            .height('100%')
            .backgroundColor(color)
            .scale({ x: this.scaleArray[index], y: this.scaleArray[index] })
            .animation({ duration: PAGE_DURATION, curve: Curve.Linear })
            .borderRadius(12)
        }, (color: Color, index: number) => `card_${index}`);
      }
      .displayMode(SwiperDisplayMode.STRETCH)
      .displayCount(1)
      .width('100%')
      .height('100%')
      .index(this.currentIndex)
      .cachedCount(1)
      .indicator(true)
      .duration(SWIPER_DURATION)
      .itemSpace(0)
      .prevMargin(20)
      .nextMargin(20)
      .curve(Curve.Linear)
      .onGestureSwipe((i, e) => this.onGestureSwipe(i, e))
      .onAnimationStart((i, t) => this.onAnimationStart(i, t))
      .onChange(i => this.currentIndex = i)
      .onAnimationEnd(() => this.startSwiperOffset = 0)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}
Enter fullscreen mode Exit fullscreen mode

Test Results

  • Verified that the middle card scales to MAX while neighbors scale to MIN, with smooth interpolation during drag and settle.
  • With displayMode=STRETCH and tuned prevMargin/nextMargin, partial neighbors remain visible.

Q777.gif

Limitations or Considerations

  • Choose DRAGGING_MAX_DISTANCE, prevMargin/nextMargin, and cachedCount based on screen width & memory.
  • If content is heavy (e.g., large images), keep item views light to prevent jank during per-frame callbacks.
  • For wearables (round screens), reduce margins and scale delta for better legibility and avoid edge clipping.
  • indicator(true) can visually collide with large margins; hide or reposition if needed.

Related Documents or Links

Written by Bunyamin Eymen Alagoz

Top comments (0)