How to Create a Snapping Stepper in Nativescript

In this article, we'll cover how to create a custom stepper component that can be incremented/decremented using both a touch and a pan…

How to Create a Snapping Stepper in Nativescript poster

Take control of your career. Build JavaScript mobile apps.

ng atlanta

Catch Dave Coffin, Nathan Walker, and Alex Ziskind at ngAtlanta in February 2020 for an advanced NativeScript with Angular workshop called  Breathe life into mobile UX with solid architecture lessons. You can register now and take your NativeScript skills up a notch.  Register here.

In this article, we'll cover how to create a custom stepper component that can be incremented/decremented using both a touch and a pan gesture. This tutorial not only teaches how to create a fancy UI component using NativeScript provided widgets (no third party plugins), but it shows how to combine some pretty complex interactions using gestures, animations, and styling.


This is what the stepper looks like, but it's what it FEELS like that's really cool. Check out the link at the end of the article to the playground sample, if you're too impatient to build it :)


Snapping Stepper


Creating the Component


Let's start off with writing an interface for the properties that we can set for the stepper, and setting up default values for those


// stepper-config.ts
export interface StepperConfig {
    width?: number,
    height?: number,
    backgroundColor?: string,
    textColor?: string,
    focusBackgroundColor?: string,
    focusTextColor?: string,
    startingNum?: number,
    limitLower?: number,
    limitUpper?: number
}

// snapping-stepper.component.ts
const DEFAULT_CONFIG: StepperConfig = {
    width: 150,
    height: 50,
    backgroundColor: '#1976d2',
    textColor: '#ffffff',
    focusBackgroundColor: '#2196f3',
    focusTextColor: '#ffffff',
    startingNum: 50,
    limitLower: 0,
    limitUpper: 100
}

We will then use the DEFAULT_CONFIG value as the default value for our stepper template to use. The stepperConfig property that gets passed in from the parent component will be merged with the default values - replacing the default values of the properties defined in the parent component.


// snapping-stepper.component.ts
@Input() stepperConfig: StepperConfig;          // properties passed in by parent
_stepperConfig: StepperConfig = DEFAULT_CONFIG; // properties that will be used by component

ngOnChanges(changes: SimpleChanges): void {
    if (changes.stepperConfig) {
        // merge and replace the previous values with the parent defined values
        this._stepperConfig = { ...DEFAULT_CONFIG, ...changes.stepperConfig.currentValue };
    }
}

Moving on to the template for our stepper. It will be comprised of a GridLayout with 3 wildcard columns containing 3 GridLayouts and a Label in each one to display the steppers and count


<!-- snapping-stepper.component.html -->
<GridLayout columns="*, *, *" [width]="_stepperConfig.width" [height]="_stepperConfig.height"
    [backgroundColor]="_stepperConfig.backgroundColor" [borderRadius]="10">
    <GridLayout col="0">
        <Label text="-" verticalAlignment="center" horizontalAlignment="center"
            [color]="_stepperConfig.textColor"></Label>
    </GridLayout>

    <GridLayout col="2">
        <Label text="+" verticalAlignment="center" horizontalAlignment="center"
            [color]="_stepperConfig.textColor"></Label>
    </GridLayout>

    <GridLayout col="1" [backgroundColor]="_stepperConfig.focusBackgroundColor"
        [borderRadius]="10">
        <Label [text]="stepCountSubject | async" verticalAlignment="center"
            horizontalAlignment="center" [color]="_stepperConfig.focusTextColor"></Label>
    </GridLayout>
</GridLayout>

We can now add basic functionalities to the component, to increment, decrement and emit values, which will be wired up with the touch and pan gestures


// snapping-stepper.component.ts
@Output() valueChange = new EventEmitter<number>();                                       // to emit an event to the parent component

public stepCount: number = this._stepperConfig.startingNum;                               // to keep track of the current count
public stepCountSubject: BehaviorSubject<number> = new BehaviorSubject(this.stepCount);  // to update the view with the current count

stepNegative(shouldEmitValue: boolean = true): void {
  // decrement if still within bounds
  if (this.stepCount > this._stepperConfig.limitLower) {
    this.stepCount -= 1;
    this.stepCountSubject.next(this.stepCount);

    if (shouldEmitValue) {
      this.emitCountValue();
    }
  }
}

stepPositive(shouldEmitValue: boolean = true): void {
  // increment if still within bounds
  if (this.stepCount < this._stepperConfig.limitUpper) {
    this.stepCount += 1;
    this.stepCountSubject.next(this.stepCount);

    if (shouldEmitValue) {
      this.emitCountValue();
    }
  }
}

emitCountValue(): void {
  this.valueChange.emit(this.stepCount);
}

Adding touch gesture

The stepper will increment one by one if tapped, but will increment with increasing speed when you hold it.


To achieve this effect, we will use a touch gesture, since we will need the on down (finger down) and on up (finger up) events.
First we will register a touch event on the plus and minus buttons by adding the following:


<!-- snapping-stepper.component.html -->
...
<GridLayout (touch)="onStepTouch($event, 'negative')" col="0">
    <Label text="-" verticalAlignment="center" horizontalAlignment="center"
        [color]="_stepperConfig.textColor"></Label>
</GridLayout>

<GridLayout (touch)="onStepTouch($event, 'positive')" col="2">
    <Label text="+" verticalAlignment="center" horizontalAlignment="center"
        [color]="_stepperConfig.textColor"></Label>
</GridLayout>
...

Before attaching the handler, we will need a few additional properties that we need to keep track, since we will be having a similar one for the pan event, lets name this touchOpt, which will contain the following types and defauls:


// snapping-stepper.component.ts
private touchOpt: { timer: any, interval: number } = {
  timer: null,              // instance of the timer
  interval: 500             // max interval for timer to increment
};

We will then add a handler for the touch events in the component file:


// snapping-stepper.component.ts
onStepTouch(args, state: 'positive' | 'negative') {
  // touch only gets triggered on first down, then when the fingers move, and up
  // it doesn't keep firing when you hold down your finger
  if (args.action === 'down') {
    // finger down event
  } else if (args.action === 'up') {
    // finger up event
  }
}

To achieve the acceleration effect, we will use a setTimeout recursively as we update the interval - lowering it everytime it gets called.


// snapping-stepper.component.ts
startTouchTimer(state: 'positive' | 'negative'): void {
  if (state === 'positive' && this.stepCount < this._stepperConfig.limitUpper) {
    this.stepPositive(false);
    this.touchOpt.interval = this.touchOpt.interval * 0.8;	// accelerate
    this.touchOpt.timer = setTimeout(this.startTouchTimer.bind(this, state), this.touchOpt.interval);
  } else if (state === 'negative' && this.stepCount > this._stepperConfig.limitLower) {
    this.stepNegative(false);
    this.touchOpt.interval = this.touchOpt.interval * 0.8;  // accelerate
    this.touchOpt.timer = setTimeout(this.startTouchTimer.bind(this, state), this.touchOpt.interval);
  } else {
    // done counting
    this.clearTouchTimer();
  }
}

clearTouchTimer(): void {
  // if timer exist, stop and set it to null
  if (this.touchOpt.timer) {
    clearTimeout(this.touchOpt.timer);
    this.touchOpt.timer = null;
  }
  // reset interval to initial speed
  this.touchOpt.interval = TOUCH_SPEED;
}

Lets update the previous touch handler to use our new startTouchTimer and clearTouchTimer functions. We will need to start the timer on the down event, and stop the timer on the up event, and just to be safe, we will clear the timer also on the down event before starting it, to make sure we don't have multiple timers running.


// snapping-stepper.component.ts
onStepTouch(args, state: 'positive' | 'negative') {
  if (args.action === 'down') {
    // clear timer before starting new timer (in case there is a timer already running);
    this.clearTouchTimer();
    // start the timer when finger is first down
    this.startTouchTimer(state);
  } else if (args.action === 'up') {
    // kill timer after finger is lifted
    this.clearTouchTimer();
    this.emitCountValue();
  }
}

Adding pan gesture

We will use a similar approach for our pan gestures, but instead of using the setTimeout timer, we will use setInterval, since it doesn't need to have varying intervals. Lets first attach the pan gesture event to our middle layout of our stepper.


<!-- snapping-stepper.component.html -->
...
<GridLayout col="1" [backgroundColor]="_stepperConfig.focusBackgroundColor"
    [borderRadius]="10" (pan)="onCountPan($event)">
    <Label [text]="stepCountSubject | async" verticalAlignment="center"
        horizontalAlignment="center" [color]="_stepperConfig.focusTextColor"></Label>
</GridLayout>
...

Like the touch event, we will also need several additional properties that we need to keep track of in the pan event, which we will group as an object called panOpt with the following type and defaults:


// snapping-stepper.component.ts
private panOpt: { timer: any, prevDeltaX: number, direction: 'left' | 'right' | null } = {
  timer: null,      // to keep track of the timer instance
  prevDeltaX: 0,    // to keep track of how far the component has been panned
  direction: null   // to keep track of what the previous direction of the pan was
};

We will then add a handler on the component file, which gets triggered everytime it detects a pan on the component. To get a consistent speed for the increment and decrement of the counts, we will need to use a timer. This is because, the pan event will be fired for every up, down, and move - if we increment everytime and we move right, and decrement everytime we move left, the speed will depend on how much you move your finger, making it inconsistent.


With the timer, we can listen to when the finger starts moving and when it switches direction and start and stop the timer accordingly. In the following snippet, we stop and start the timer everytime there is a switch in the direction of the pan, and stop the timer when the finger is no longer touching. The finger up event also fires an animation which moves the middle part of the stepper back to its original position, creating the snapping effect.


// snapping-stepper.component.ts
onCountPan(args) {
  let grdLayout: GridLayout = <GridLayout>args.object;
  let newX: number = grdLayout.translateX + args.deltaX - this.panOpt.prevDeltaX;

  if (args.state === 0) {
    // finger down
    this.panOpt.prevDeltaX = 0;
  } else if (args.state === 2) {
    // finger moving
    if (Math.abs(newX) < this._stepperConfig.width / 3 && (Math.abs(args.deltaY) < this._stepperConfig.height)) {
      grdLayout.translateX = newX;

      // increment or decrement stepper depending on pan direction
      // only increment if panning is still within bounds
      if (newX > 0 && this.panOpt.direction !== 'right') {
        // pan right
        this.clearPanTimer();
        // use set interval to make the increment/decrement speed more consistent,
        // not depending on how much movement the panning captures
        this.panOpt.timer = setInterval(() => {
          this.stepPositive(false);
        }, 10);
      } else if (newX <= 0 && this.panOpt.direction !== 'left') {
        // pan left
        this.clearPanTimer();
        this.panOpt.timer = setInterval(() => {
          this.stepNegative(false);
        }, 10);
      }
    } else {
      // out of bounds
      this.clearPanTimer();
    }
    // update how far the pan has moved the component
    this.panOpt.prevDeltaX = args.deltaX;
  } else if (args.state === 3) {
    // finger up
    this.panOpt.prevDeltaX = 0;

    // snap back to original position
    grdLayout.animate({
      translate: { x: 0, y: 0 },
      curve: AnimationCurve.cubicBezier(0, 0.405, 0, 1.285),
      duration: 200
    });
    this.panOpt.direction = null;
    this.clearPanTimer();
    this.emitCountValue();
  }
}

clearPanTimer() {
  if (this.panOpt.timer) {
    clearInterval(this.panOpt.timer);
    this.panOpt.timer = null;
  }
}

Checkout the demo on nativescript playground and the component's source code on github


William is a Nativescript Ambassador based in Chicago, IL. He is a front end enthusiast with a love for all things web.

Did you enjoy this? Share it!

Take control of your career. Build JavaScript mobile apps.