mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-23 14:01:20 +08:00
feat(segment): implement iOS 13 segment with animation (#19036)
Changes Closes #18663 * Converts Segment to shadow * Enables gesture to swipe between segment buttons * Adds indicator transition to slide the indicator between buttons * Updates global theme variables * Removes activated state, now handled by the gesture * Updates iOS to latest iOS 13 UI * Ensures customization is working for the buttons and indicator * Updates the e2e tests
This commit is contained in:

committed by
Liam DeBeasi

parent
8e11f79fcc
commit
dc66ce48e1
@ -1,8 +1,10 @@
|
||||
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Listen, Prop, Watch, h } from '@stencil/core';
|
||||
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Listen, Prop, State, Watch, h, writeTask } from '@stencil/core';
|
||||
|
||||
import { getIonMode } from '../../global/ionic-global';
|
||||
import { Color, SegmentChangeEventDetail, StyleEventDetail } from '../../interface';
|
||||
import { createColorClasses } from '../../utils/theme';
|
||||
import { Gesture, GestureDetail } from '../../utils/gesture';
|
||||
import { pointerCoord } from '../../utils/helpers';
|
||||
import { createColorClasses, hostContext } from '../../utils/theme';
|
||||
|
||||
/**
|
||||
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
|
||||
@ -13,13 +15,16 @@ import { createColorClasses } from '../../utils/theme';
|
||||
ios: 'segment.ios.scss',
|
||||
md: 'segment.md.scss'
|
||||
},
|
||||
scoped: true
|
||||
shadow: true
|
||||
})
|
||||
export class Segment implements ComponentInterface {
|
||||
|
||||
private gesture?: Gesture;
|
||||
private didInit = false;
|
||||
private checked?: HTMLIonSegmentButtonElement;
|
||||
|
||||
@Element() el!: HTMLElement;
|
||||
@Element() el!: HTMLIonSegmentElement;
|
||||
|
||||
@State() activated = false;
|
||||
|
||||
/**
|
||||
* The color to use from your application's color palette.
|
||||
@ -35,6 +40,8 @@ export class Segment implements ComponentInterface {
|
||||
|
||||
/**
|
||||
* If `true`, the segment buttons will overflow and the user can swipe to see them.
|
||||
* In addition, this will disable the gesture to drag the indicator between the buttons
|
||||
* in order to swipe to see hidden buttons.
|
||||
*/
|
||||
@Prop() scrollable = false;
|
||||
|
||||
@ -43,14 +50,6 @@ export class Segment implements ComponentInterface {
|
||||
*/
|
||||
@Prop({ mutable: true }) value?: string | null;
|
||||
|
||||
@Watch('value')
|
||||
protected valueChanged(value: string | undefined) {
|
||||
if (this.didInit) {
|
||||
this.updateButtons();
|
||||
this.ionChange.emit({ value });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted when the value property has changed.
|
||||
*/
|
||||
@ -62,10 +61,32 @@ export class Segment implements ComponentInterface {
|
||||
*/
|
||||
@Event() ionStyle!: EventEmitter<StyleEventDetail>;
|
||||
|
||||
@Watch('value')
|
||||
protected valueChanged(value: string | undefined) {
|
||||
if (this.didInit) {
|
||||
this.updateButtons();
|
||||
this.ionChange.emit({ value });
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('disabled')
|
||||
disabledChanged() {
|
||||
if (this.gesture && !this.scrollable) {
|
||||
this.gesture.enable(!this.disabled);
|
||||
}
|
||||
}
|
||||
|
||||
@Listen('ionSelect')
|
||||
segmentClick(ev: CustomEvent) {
|
||||
const selectedButton = ev.target as HTMLIonSegmentButtonElement;
|
||||
this.value = selectedButton.value;
|
||||
const current = ev.target as HTMLIonSegmentButtonElement;
|
||||
const previous = this.checked;
|
||||
this.value = current.value;
|
||||
|
||||
if (previous && this.scrollable) {
|
||||
this.checkButton(previous, current);
|
||||
}
|
||||
|
||||
this.checked = current;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
@ -78,11 +99,219 @@ export class Segment implements ComponentInterface {
|
||||
this.emitStyle();
|
||||
}
|
||||
|
||||
componentDidLoad() {
|
||||
componentWillLoad() {
|
||||
this.emitStyle();
|
||||
}
|
||||
|
||||
async componentDidLoad() {
|
||||
this.updateButtons();
|
||||
this.setCheckedClasses();
|
||||
|
||||
this.gesture = (await import('../../utils/gesture')).createGesture({
|
||||
el: this.el,
|
||||
gestureName: 'segment',
|
||||
gesturePriority: 100,
|
||||
threshold: 0,
|
||||
passive: false,
|
||||
onStart: ev => this.onStart(ev),
|
||||
onMove: ev => this.onMove(ev),
|
||||
onEnd: ev => this.onEnd(ev),
|
||||
});
|
||||
this.gesture.enable(!this.scrollable);
|
||||
this.disabledChanged();
|
||||
|
||||
this.didInit = true;
|
||||
}
|
||||
|
||||
onStart(detail: GestureDetail) {
|
||||
this.activate(detail);
|
||||
}
|
||||
|
||||
onMove(detail: GestureDetail) {
|
||||
this.setNextIndex(detail);
|
||||
}
|
||||
|
||||
onEnd(detail: GestureDetail) {
|
||||
this.activated = false;
|
||||
|
||||
this.setNextIndex(detail, true);
|
||||
|
||||
detail.event.preventDefault();
|
||||
detail.event.stopImmediatePropagation();
|
||||
|
||||
this.addRipple(detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* The gesture blocks the segment button ripple. This
|
||||
* function adds the ripple based on the checked segment
|
||||
* and where the cursor ended.
|
||||
*/
|
||||
private addRipple(detail: GestureDetail) {
|
||||
const buttons = this.getButtons();
|
||||
const checked = buttons.find(button => button.checked === true);
|
||||
|
||||
const ripple = checked!.shadowRoot!.querySelector('ion-ripple-effect');
|
||||
|
||||
if (!ripple) { return; }
|
||||
|
||||
const { x, y } = pointerCoord(detail.event);
|
||||
|
||||
ripple.addRipple(x, y).then(remove => remove());
|
||||
}
|
||||
|
||||
private activate(detail: GestureDetail) {
|
||||
const clicked = detail.event.target as HTMLIonSegmentButtonElement;
|
||||
const buttons = this.getButtons();
|
||||
const checked = buttons.find(button => button.checked === true);
|
||||
|
||||
// Make sure we are only checking for activation on a segment button
|
||||
// since disabled buttons will get the click on the segment
|
||||
if (clicked.tagName !== 'ION-SEGMENT-BUTTON') {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are no checked buttons, set the current button to checked
|
||||
if (!checked) {
|
||||
clicked.checked = true;
|
||||
}
|
||||
|
||||
// If the gesture began on the clicked button with the indicator
|
||||
// then we should activate the indicator
|
||||
if (clicked.checked) {
|
||||
this.activated = true;
|
||||
}
|
||||
}
|
||||
|
||||
private getIndicator(button: HTMLIonSegmentButtonElement): HTMLDivElement | null {
|
||||
return button.shadowRoot && button.shadowRoot.querySelector('.segment-button-indicator');
|
||||
}
|
||||
|
||||
private checkButton(previous: HTMLIonSegmentButtonElement, current: HTMLIonSegmentButtonElement) {
|
||||
const previousIndicator = this.getIndicator(previous);
|
||||
const currentIndicator = this.getIndicator(current);
|
||||
|
||||
if (previousIndicator === null || currentIndicator === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousClientRect = previousIndicator.getBoundingClientRect();
|
||||
const currentClientRect = currentIndicator.getBoundingClientRect();
|
||||
|
||||
const widthDelta = previousClientRect.width / currentClientRect.width;
|
||||
const xPosition = previousClientRect.left - currentClientRect.left;
|
||||
|
||||
// Scale the indicator width to match the previous indicator width
|
||||
// and translate it on top of the previous indicator
|
||||
const transform = `translate3d(${xPosition}px, 0, 0) scaleX(${widthDelta})`;
|
||||
|
||||
writeTask(() => {
|
||||
// Remove the transition before positioning on top of the previous indicator
|
||||
currentIndicator.classList.remove('segment-button-indicator-animated');
|
||||
currentIndicator.style.setProperty('transform', transform);
|
||||
|
||||
// Force a repaint to ensure the transform happens
|
||||
currentIndicator.getBoundingClientRect();
|
||||
|
||||
// Add the transition to move the indicator into place
|
||||
currentIndicator.classList.add('segment-button-indicator-animated');
|
||||
|
||||
// Remove the transform to slide the indicator back to the button clicked
|
||||
currentIndicator.style.setProperty('transform', '');
|
||||
});
|
||||
|
||||
current.checked = true;
|
||||
this.setCheckedClasses();
|
||||
}
|
||||
|
||||
private setCheckedClasses() {
|
||||
const buttons = this.getButtons();
|
||||
const index = buttons.findIndex(button => button.checked === true);
|
||||
const next = index + 1;
|
||||
|
||||
// Keep track of the currently checked button
|
||||
this.checked = buttons.find(button => button.checked === true);
|
||||
|
||||
for (const button of buttons) {
|
||||
button.classList.remove('segment-button-after-checked');
|
||||
}
|
||||
if (next < buttons.length) {
|
||||
buttons[next].classList.add('segment-button-after-checked');
|
||||
}
|
||||
}
|
||||
|
||||
private setNextIndex(detail: GestureDetail, isEnd = false) {
|
||||
const isRTL = document.dir === 'rtl';
|
||||
const activated = this.activated;
|
||||
const buttons = this.getButtons();
|
||||
const index = buttons.findIndex(button => button.checked === true);
|
||||
const previous = buttons[index];
|
||||
let current;
|
||||
let nextIndex;
|
||||
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the element that the touch event started on in case
|
||||
// it was the checked button, then we will move the indicator
|
||||
const rect = previous.getBoundingClientRect() as DOMRect;
|
||||
const left = rect.left;
|
||||
const width = rect.width;
|
||||
|
||||
// Get the element that the gesture is on top of based on the currentX of the
|
||||
// gesture event and the Y coordinate of the starting element, since the gesture
|
||||
// can move up and down off of the segment
|
||||
const currentX = detail.currentX;
|
||||
const previousY = rect.y;
|
||||
const nextEl = document.elementFromPoint(currentX, previousY) as HTMLIonSegmentButtonElement;
|
||||
|
||||
const decreaseIndex = isRTL ? currentX > (left + width) : currentX < left;
|
||||
const increaseIndex = isRTL ? currentX < left : currentX > (left + width);
|
||||
|
||||
// If the indicator is currently activated then we have started the gesture
|
||||
// on top of the checked button so we need to slide the indicator
|
||||
// by checking the button next to it as we move
|
||||
if (activated && !isEnd) {
|
||||
// Decrease index, move left in LTR & right in RTL
|
||||
if (decreaseIndex) {
|
||||
const newIndex = index - 1;
|
||||
|
||||
if (newIndex >= 0) {
|
||||
nextIndex = newIndex;
|
||||
}
|
||||
// Increase index, moves right in LTR & left in RTL
|
||||
} else if (increaseIndex) {
|
||||
if (activated && !isEnd) {
|
||||
|
||||
const newIndex = index + 1;
|
||||
|
||||
if (newIndex < buttons.length) {
|
||||
nextIndex = newIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nextIndex !== undefined && !buttons[nextIndex].disabled) {
|
||||
current = buttons[nextIndex];
|
||||
}
|
||||
}
|
||||
|
||||
// If the indicator is not activated then we will just set the indicator
|
||||
// to the element where the gesture ended
|
||||
if (!activated && isEnd) {
|
||||
current = nextEl;
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previous !== current) {
|
||||
this.checkButton(previous, current);
|
||||
}
|
||||
}
|
||||
|
||||
private emitStyle() {
|
||||
this.ionStyle.emit({
|
||||
'segment': true
|
||||
@ -102,15 +331,20 @@ export class Segment implements ComponentInterface {
|
||||
|
||||
render() {
|
||||
const mode = getIonMode(this);
|
||||
|
||||
return (
|
||||
<Host
|
||||
class={{
|
||||
...createColorClasses(this.color),
|
||||
[mode]: true,
|
||||
'in-toolbar': hostContext('ion-toolbar', this.el),
|
||||
'in-toolbar-color': hostContext('ion-toolbar[color]', this.el),
|
||||
'segment-activated': this.activated,
|
||||
'segment-disabled': this.disabled,
|
||||
'segment-scrollable': this.scrollable
|
||||
}}
|
||||
>
|
||||
<slot></slot>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user