Ionic-framework: ion-range start and end events

Created on 20 Mar 2019  路  42Comments  路  Source: ionic-team/ionic-framework

Feature Request

Ionic version:


[x] 4.x

Describe the Feature Request

As discussed here https://github.com/ionic-team/ionic/issues/17299, it would be nice to have a start event triggered when knob is pressed and an end event triggered when the knob is released like the behavior of ionFocus and ionBlur in ionic v3.

Thanks

core feature request

Most helpful comment

@danielehrhardt thank you for the idea!

I've extended it (EDIT: an simplified it!) a bit:

  • support mouse events when running in a Desktop browser
  • support ionEnd event
  • support clicking/touching the range slider
  • emit RangeValue on ionStart/ionEnd
  • automatically apply to all ion-ranges

Hopefully we won't have to use this hack for long :-)

import { Directive, ElementRef, EventEmitter, Output, HostListener } from '@angular/core';

import { RangeValue } from '@ionic/core';
import { IonRange } from '@ionic/angular';

@Directive({
    // tslint:disable-next-line:directive-selector
    selector: 'ion-range'
})
export class RangeEventsDirective {
    @Output() public ionStart: EventEmitter<RangeValue> = new EventEmitter();
    @Output() public ionEnd: EventEmitter<RangeValue> = new EventEmitter();

    protected isSliding: boolean = false;

    public constructor(protected elemRef: ElementRef<IonRange>) {}

    @HostListener('mousedown', ['$event'])
    @HostListener('touchstart', ['$event'])
    public onStart(ev: Event): void {
        this.isSliding = true;
        this.ionStart.emit(this.elemRef.nativeElement.value);
        ev.preventDefault();
    }

    @HostListener('mouseup', ['$event'])
    @HostListener('window:mouseup', ['$event'])
    @HostListener('touchend', ['$event'])
    public onEnd(ev: Event): void {
        if (this.isSliding) {
            this.isSliding = false;
            this.ionEnd.emit(this.elemRef.nativeElement.value);
            ev.preventDefault();
        }
    }
}

All 42 comments

I gave it a shot. It's my first pull request-ish, so be gentle ;-) #18250

Hi there,

Thanks for the feature request. How do the start and end events differ from the focus and blur events? The focus event should fire when the knob is pressed, and the blur is fired when the knob is released.

Thanks!

That was the behavior in ionic 3 but it has changed in v4 see: https://github.com/ionic-team/ionic/issues/17299#issuecomment-458490016

Hi everyone,

I spoke with the team, and there are a couple things going on here:

  1. The focus/blur behavior in ion-range isn't quite right. focus should fire when the element comes into focus (tabbing in, clicking, etc). blur should fire when the element loses focus (clicking outside of the range).

  2. We need a way to capture mousedown/mouseup since essentially what we were capturing in v3. I think the PR that was created attempts to resolve this, so I will work with the PR author on cleaning this up.

Thanks!

@liamdebeasi focus/blur seems to work as advertised (like you would expect any input component to behave).

I committed another change that also triggers start/end when changing range using the keyboard.
The start-event will always contain the range value before the knob was moved and the end-event will contain the final range value after the knob has been released.

I'm not sure if I'm supposed to squash the commits (or you can do it?) or how - sorry!

Hi @biesbjerg,

I created a CodePen that shows the ion-range behavior vs the native range input behavior: https://codepen.io/liamdebeasi/pen/eaZyxW. Clicking the track on ion-range fires both the focus and blur events, which is not correct.

My focus/blur comment was in regards to the existing behavior of the ion-range, not in regards to your PR. I will post any feedback for that on the PR page. 馃檪

Don't worry about squashing commits, we will squash and merge your PR when it's ready.

Thanks!

@liamdebeasi I agree that the current behavior is not correct regarding focus / blur when clicking the range. I think the issue comes from this line, where the closest knob is getting the focus, triggering focusout on the range. Anyway, this should probably be tracked in a different issue.

What are your thoughts about merging the PR adding start and end events in its current form?

Hi everyone,

We had been focused on shipping 4.6.0 (and then subsequently fixing some bugs). I haven't forgotten about this, and I hope to loop back to it soon.

Thanks! 馃檪

@liamdebeasi I need to get the value when range has stopped moving. I'm using it for seeking in an audio element, and it is not optimal to set position hundreds of times while moving the knob, when all I neeed is the final value. I also tried with various debouncing tricks, which makes it feel sluggish and buggy if audio starts buffering - believe me, I've tried __hard__ to find something that would work for me, but in the end an onDragEnd event is really what I need.

For comparison, material slider has two outputs (change is what I'm after):

image

Note: Native range's onchange emits when knob has been released, not multiple times while dragging the knob, like ion-range currently does.

https://codepen.io/biesbjerg/pen/EqxOoW

Ah interesting. Can we maybe just have 1 event then rather than start and end events?

change and input events covers most use cases I can think.

Are you suggesting changing the behavior of change and adding input like material?

My main reservation is that we would be adding 2 new APIs to ion-range to get one desired behavior.

I think ionChange should remain the same since it is in line with how ionChange events behave for other components. Adding an event to know when the knob has been dragged could be useful though.

It seems like we could emit something here: https://github.com/ionic-team/ionic/blob/master/core/src/components/range/range.tsx#L260

Are you interested in knowing when the knob has been dragged, or just when the range has been updated as a result of a user?

edit: we might want to consider emitting the same event here as well since users could click along the track: https://github.com/ionic-team/ionic/blob/master/core/src/components/range/range.tsx#L238

Personally I'm interested in knowing the value after the range has been dragged and value updated, letting me know the position to seek to in the audio.

Like in my PR:
https://github.com/ionic-team/ionic/pull/18250/files#diff-96cc2845be9d5c7024e04f2e1856b65fR246
https://github.com/ionic-team/ionic/pull/18250/files#diff-96cc2845be9d5c7024e04f2e1856b65fR304

Note: I really think changing ionChange's behavior to emit when knob has been released is more in line with how other components work, since technically the value has not changed until that happens (according to how native input[type=range] works).

I do remember I wanted to add TapticEngine feedback (Cordova plugin) to a slider, which requires the following calls:

Tell the taptic engine that a gesture for a selection change is starting.
TapticEngine.gestureSelectionStart();

Tell the taptic engine that a selection changed during a gesture.
TapticEngine.gestureSelectionChanged();

Tell the taptic engine we are done with a gesture. This needs to be called lest resources are not properly recycled.
TapticEngine.gestureSelectionEnd();

In this case you would actually need start, end and the current behavior of change.

I do get that you're trying to minimize API that you have to maintain, but in this case it might hurt the end user by being inflexible.

One idea we've been playing with in general is updating the ionChange object to include the type of event that triggered it.

So in this case the interface (at least for ion-range) would be something like:

interface ionChangeDetail {
  value: number
  event: Event | null
}

event would take on MouseEvent and similar events. If event is null then we know ionChange was programatic. Would this work for you?

edit: forgot to explain how you'd use this:

You would listen for ionChange and if event !== null then you know the change was done by a user (either through mouse events, keyboard event, etc).

Sounds good to have access to the event that triggered the change to be able to distinguish user changes from programmatic changes.

Would ionChange still fire while dragging the knob or when released? If ionChange triggers when knob is released, not while dragging, I'd be a happy camper :-) But others might need realtime values while dragging is occurs.

onChange(detail: ionChangeDetail) {
  if (detail.event !== null) {
    console.log('Knob was released, seek to position:', detail.value);
  }
}

I think it will be better for the developer experience, to have the start and end event rather having to check detail.event. It's better to understand what's going on when reading code. Also in my case, I'd like to know when the user has pressed the knob to stop some actions, and restart these actions when it's released.

@biesbjerg,

I can check in with the team to see what their thoughts on that are. Are you able to use the debounce functionality for this purpose?

Cool :)

No, debounce make it seem sluggish and buggy. I've been beating this horse for a while now. An event on knob release is the only acceptable solution I've found.

I just checked my code, and I rely on both start and end events (it's been a while, currently I'm using a custom built of Ionic to support it).

import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter, ViewChild } from '@angular/core';

import { IonRange } from '@ionic/angular';

@Component({
    selector: 'ip-player-seekbar',
    templateUrl: 'player-seekbar.component.html',
    styleUrls: ['player-seekbar.component.scss'],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class PlayerSeekbarComponent {

    @ViewChild('seekbar') public seekbar: IonRange;

    @Input() public isWaitingForData: false;
    @Input() public currentTime: number = 0;
    @Input() public duration: number = 0;

    @Output() public seekStart: EventEmitter<number> = new EventEmitter();
    @Output() public seekEnd: EventEmitter<number> = new EventEmitter();

    protected _currentTimeDisplay: number = 0;

    protected isSeeking: boolean = false;

    public get currentTimeDisplay(): number {
        if (this.isSeeking) {
            return this.seekbar.value as number;
        }
        return this.currentTime;
    }

    public set currentTimeDisplay(value: number) {
        this._currentTimeDisplay = value;
    }

    public get remainingTimeDisplay(): number {
        return this.duration - this.currentTimeDisplay;
    }

    public get isDisabled(): boolean {
        return this.isWaitingForData || isNaN(this.duration) || this.duration <= 0;
    }

    public onSeekStart(timeInSeconds: number): void {
        this.isSeeking = true;
        this.seekStart.emit(timeInSeconds);
    }

    public onSeekEnd(timeInSeconds: number): void {
        this.isSeeking = false;
        this.seekEnd.emit(timeInSeconds);
    }

}
<ion-range
    #seekbar
    min="0"
    [max]="duration"
    [disabled]="isDisabled"
    [value]="currentTimeDisplay"
    (ionStart)="onSeekStart($event.target.value)"
    (ionEnd)="onSeekEnd($event.target.value)"></ion-range>

<ion-grid>
    <ion-row>
        <ion-col class="current-time">
            {{ currentTimeDisplay | duration }}
        </ion-col>
        <ion-col class="remaining-time">
            <span *ngIf="remainingTimeDisplay > 0">-</span>{{ remainingTimeDisplay | duration }}
        </ion-col>
    </ion-row>
</ion-grid>

image

Thanks for the follow up. What if you debounced from your ionChange handler (rather than from within the ion-range component)? So the currentTimeDisplay would update in real time, but you wouldn't actually do the seeking until a certain timeout.

I'm not sure I follow?

Something like:

    public onChange(timeInSeconds: number): void {
        clearInterval(this.disableUpdatesTimer);
        this.isSeeking = true;
        this.disableUpdatesTimer = setTimeout(() => {
            this.isSeeking = false;
            console.log('SEEK END');
        }, 250);

One problem is that it gets triggered by [value]="currentTimeDisplay" binding in template. Another issue is that it wouldn't 'feel' very good seeking, trying to get to a specific position, since actual seeking is delayed by a debounce, compared to doing it at once when knob is released.

Ok thanks. I'm going to touch base with the team regarding this.

I think it is really important to have the ionStart and ionEnd Event. So I would really suggest merging this Pull request. Currently, i am not able to use this Component. There is no option to save the Stuff after Change.

This is a tmp fix. Until the Fix is merged to Ionic.

import { Directive, ElementRef, EventEmitter, Output } from '@angular/core';

@Directive({
  selector: '[range]'
})
export class RangeDirective {
  knobInterval;
  knob: HTMLElement[];

  /**
   * @description event output emitter
   * @property end
   * @type EventEmitter<any>
   * @public
   * @default new EventEmitter<any>()
   */
  @Output('end') public end: EventEmitter<any> = new EventEmitter<any>();


  constructor(
    private el: ElementRef
  ) { }


  /**
   * @description after view initialises, recalcultae height of textarea
   * @method onInput
   * @param nativeElement
   */
  public ngAfterViewInit(): void {
    this.initKnob();
  }

  initKnob(): void {
    this.knobInterval = setInterval(() => {
      const element = this.el.nativeElement;

      if (element.shadowRoot === null) {
        element.attachShadow({ mode: 'open' });
      }
      this.knob = element.shadowRoot.querySelectorAll('.range-knob-handle');


      if (this.knob.length > 0) {
        clearInterval(this.knobInterval);
        this.knob.forEach(knob => {
          knob.ontouchend = () => {
            this.end.emit('change');
          }
        });
      }
    }, 100)
  }

}

@danielehrhardt thank you for the idea!

I've extended it (EDIT: an simplified it!) a bit:

  • support mouse events when running in a Desktop browser
  • support ionEnd event
  • support clicking/touching the range slider
  • emit RangeValue on ionStart/ionEnd
  • automatically apply to all ion-ranges

Hopefully we won't have to use this hack for long :-)

import { Directive, ElementRef, EventEmitter, Output, HostListener } from '@angular/core';

import { RangeValue } from '@ionic/core';
import { IonRange } from '@ionic/angular';

@Directive({
    // tslint:disable-next-line:directive-selector
    selector: 'ion-range'
})
export class RangeEventsDirective {
    @Output() public ionStart: EventEmitter<RangeValue> = new EventEmitter();
    @Output() public ionEnd: EventEmitter<RangeValue> = new EventEmitter();

    protected isSliding: boolean = false;

    public constructor(protected elemRef: ElementRef<IonRange>) {}

    @HostListener('mousedown', ['$event'])
    @HostListener('touchstart', ['$event'])
    public onStart(ev: Event): void {
        this.isSliding = true;
        this.ionStart.emit(this.elemRef.nativeElement.value);
        ev.preventDefault();
    }

    @HostListener('mouseup', ['$event'])
    @HostListener('window:mouseup', ['$event'])
    @HostListener('touchend', ['$event'])
    public onEnd(ev: Event): void {
        if (this.isSliding) {
            this.isSliding = false;
            this.ionEnd.emit(this.elemRef.nativeElement.value);
            ev.preventDefault();
        }
    }
}

@biesbjerg
Hi! where should i put your solution? thanks!

@biesbjerg
it works, thank you for the fix, I need it for my audio player
actually my first directive I implemented :-)

Hi everyone!
I ran into this issue and used mousedown and mouseup events like you guys did on your directive.
A problem I'm facing is that the mouseup event isn't triggered if, when dragging the slider, I go outside the area of the range component. Like that the things I do when the value changes cannot be done if the user, sliding the knobs, goes out of the range component.

Are you experimenting this behaviour too?

Same behaviour here

Same behaviour here

@robsonos I obtained the behaviour I expected with a little trick. I share my code with you so maybe I can help you with this:

HTML TEMPLATE

<!-- this is the main container of my page -->
<ion-content (mouseup)="mouseUp()">

  <!-- other elements of the page here -->

  <ion-range
    [(ngModel)]="age"
    min="5"
    max="99"
    dual-knobs="true"
    [pin]="true"
    (mousedown)="activateRange()"
  >
    <ion-label slot="start">AGE</ion-label>
  </ion-range>
</ion-content>

JS

@Component({
  selector: ' ... ',
  templateUrl: ' ... ',
  styleUrls: [ ... ]
})
export class CoursesPage implements OnInit, OnDestroy {
  // NOTE THIS VARIABLE
  public rangeActive = false;
  public age = {
    lower: 0,
    upper: 99
  };

  activateRange() {
    this.rangeActive = true;
  }

  mouseUp() {
    if (this.rangeActive) {
      this.rangeActive = false;
      // here you are sure the value is changed and 
      // the user has stopped to drag the range knobs so you can use the values
    }
  }
}

Only in this way I can use a range input that when user drags the knobs going outside the input area still works and triggers the "change" event (it's not the change event, but this trick works like that) only when user releases the knob.

Hope this can help, if you have any question just ask!

@LeonardoMinatiCrispyBacon @robsonos I updated the code to fix the issue. Didn't discover it in my own apps because they're only used on touchscreen devices :-)

@biesbjerg for sure your solution is the cleanest one! 馃槂

@LeonardoMinatiCrispyBacon same line of thinking though! ;-)

@LeonardoMinatiCrispyBacon @biesbjerg thank you guys, works like a charm!

Hey all, I was triaging my own code and found this thread after I applied a workaround which is just simple addition to ion-range listeners:

(touchend)="onRangeDragEnd()" (mouseup)="onRangeDragEnd()"

I guess its Angular specific binding here and I "waste" 2 listeners, but other than that - am I missing something?

Hi @cmer4 ! I think you are missing this point:

the mouseup event isn't triggered if, when dragging the slider, I go outside the area of the range component. Like that the things I do when the value changes cannot be done if the user, sliding the knobs, goes out of the range component

Which is why we implemented the code above 馃檪
Hope I got your request right!

Ah totally makes sense. So ideally on touchstart/mousedown bound to ion-range we want to dynamically add listener for mouseup/touchend/touchcancel for the entire document/entire ionic page which would warrant user moving the knob too fast and dropping it will still trigger the "dragend" scenario. And then also remove dynamic listener to prevent mem leaks. Cool!

@liamdebeasi this discussion started around a year ago and seems to have dropped off the Ionic team radar.

I have been working on updating an ionic v3 app to v5 and noted that the ionBlur behaviour is changed so rather than firing when I let go of the knob (ie release focus / blur) nothing happens. If i then touch the screen anywhere outside the range element blur fires.

It would be really useful to have blur fire when the knob is released or failing that to have some kind of dragEnd event to catch the value when the user releases the knob.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

BilelKrichen picture BilelKrichen  路  3Comments

danbucholtz picture danbucholtz  路  3Comments

MrBokeh picture MrBokeh  路  3Comments

Macstyg picture Macstyg  路  3Comments

alexbainbridge picture alexbainbridge  路  3Comments