Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(segment, segment-view): remove percentage based indicator effects on scroll #29968

Merged
merged 10 commits into from
Oct 25, 2024
8 changes: 3 additions & 5 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1542,7 +1542,7 @@ ion-segment,css-prop,--background,md

ion-segment-button,shadow
ion-segment-button,prop,contentId,string | undefined,undefined,false,true
ion-segment-button,prop,disabled,boolean,false,false,false
ion-segment-button,prop,disabled,boolean,false,false,true
ion-segment-button,prop,layout,"icon-bottom" | "icon-end" | "icon-hide" | "icon-start" | "icon-top" | "label-hide" | undefined,'icon-top',false,false
ion-segment-button,prop,mode,"ios" | "md",undefined,false,false
ion-segment-button,prop,type,"button" | "reset" | "submit",'button',false,false
Expand Down Expand Up @@ -1608,13 +1608,11 @@ ion-segment-button,part,indicator-background
ion-segment-button,part,native

ion-segment-content,shadow
ion-segment-content,prop,disabled,boolean,false,false,false

ion-segment-view,shadow
ion-segment-view,prop,disabled,boolean,false,false,false
ion-segment-view,method,setContent,setContent(id: string, smoothScroll?: boolean) => Promise<void>
ion-segment-view,event,ionSegmentViewScroll,{ scrollDirection: string; scrollDistance: number; scrollDistancePercentage: number; },true
ion-segment-view,event,ionSegmentViewScrollEnd,{ activeContentId: string; },true
ion-segment-view,event,ionSegmentViewScroll,SegmentViewScrollEvent,true
ion-segment-view,event,ionSegmentViewScrollEnd,void,true
ion-segment-view,event,ionSegmentViewScrollStart,void,true

ion-select,shadow
Expand Down
27 changes: 6 additions & 21 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { NavigationHookCallback } from "./components/route/route-interface";
import { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./components/searchbar/searchbar-interface";
import { SegmentChangeEventDetail, SegmentValue } from "./components/segment/segment-interface";
import { SegmentButtonLayout } from "./components/segment-button/segment-button-interface";
import { SegmentViewScrollEvent } from "./components/segment-view/segment-view-interface";
import { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./components/select/select-interface";
import { SelectPopoverOption } from "./components/select-popover/select-popover-interface";
import { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface";
Expand Down Expand Up @@ -69,6 +70,7 @@ export { NavigationHookCallback } from "./components/route/route-interface";
export { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./components/searchbar/searchbar-interface";
export { SegmentChangeEventDetail, SegmentValue } from "./components/segment/segment-interface";
export { SegmentButtonLayout } from "./components/segment-button/segment-button-interface";
export { SegmentViewScrollEvent } from "./components/segment-view/segment-view-interface";
export { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./components/select/select-interface";
export { SelectPopoverOption } from "./components/select-popover/select-popover-interface";
export { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface";
Expand Down Expand Up @@ -2717,18 +2719,13 @@ export namespace Components {
"value": SegmentValue;
}
interface IonSegmentContent {
/**
* If `true`, the segment content will not be displayed.
*/
"disabled": boolean;
}
interface IonSegmentView {
/**
* If `true`, the segment view cannot be interacted with.
*/
"disabled": boolean;
/**
* This method is used to programmatically set the displayed segment content in the segment view. Calling this method will update the `value` of the corresponding segment button.
* @param id : The id of the segment content to display.
* @param smoothScroll : Whether to animate the scroll transition.
*/
Expand Down Expand Up @@ -4442,12 +4439,8 @@ declare global {
new (): HTMLIonSegmentContentElement;
};
interface HTMLIonSegmentViewElementEventMap {
"ionSegmentViewScroll": {
scrollDirection: string;
scrollDistance: number;
scrollDistancePercentage: number;
};
"ionSegmentViewScrollEnd": { activeContentId: string };
"ionSegmentViewScroll": SegmentViewScrollEvent;
"ionSegmentViewScrollEnd": void;
"ionSegmentViewScrollStart": void;
}
interface HTMLIonSegmentViewElement extends Components.IonSegmentView, HTMLStencilElement {
Expand Down Expand Up @@ -7530,10 +7523,6 @@ declare namespace LocalJSX {
"value"?: SegmentValue;
}
interface IonSegmentContent {
/**
* If `true`, the segment content will not be displayed.
*/
"disabled"?: boolean;
}
interface IonSegmentView {
/**
Expand All @@ -7543,15 +7532,11 @@ declare namespace LocalJSX {
/**
* Emitted when the segment view is scrolled.
*/
"onIonSegmentViewScroll"?: (event: IonSegmentViewCustomEvent<{
scrollDirection: string;
scrollDistance: number;
scrollDistancePercentage: number;
}>) => void;
"onIonSegmentViewScroll"?: (event: IonSegmentViewCustomEvent<SegmentViewScrollEvent>) => void;
/**
* Emitted when the segment view scroll has ended.
*/
"onIonSegmentViewScrollEnd"?: (event: IonSegmentViewCustomEvent<{ activeContentId: string }>) => void;
"onIonSegmentViewScrollEnd"?: (event: IonSegmentViewCustomEvent<void>) => void;
"onIonSegmentViewScrollStart"?: (event: IonSegmentViewCustomEvent<void>) => void;
}
interface IonSelect {
Expand Down
9 changes: 6 additions & 3 deletions core/src/components/segment-button/segment-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
/**
* If `true`, the user cannot interact with the segment button.
*/
@Prop({ mutable: true }) disabled = false;
@Prop({ mutable: true, reflect: true }) disabled = false;

/**
* Set the layout of the text and icon in the segment.
Expand Down Expand Up @@ -91,8 +91,11 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
return;
}

// Set the disabled state of the Segment Content based on the button's disabled state
segmentContent.disabled = this.disabled;
// Prevent buttons from being disabled when associated with segment content
if (this.disabled) {
console.warn(`Segment Button: Segment buttons cannot be disabled when associated with an <ion-segment-content>.`);
this.disabled = false;
}
}

disconnectedCallback() {
Expand Down
4 changes: 0 additions & 4 deletions core/src/components/segment-content/segment-content.ios.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,3 @@

// iOS Segment Content
// --------------------------------------------------

:host(.segment-content-disabled) {
opacity: $segment-button-ios-opacity-disabled;
}
4 changes: 0 additions & 4 deletions core/src/components/segment-content/segment-content.md.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,3 @@

// Material Design Segment Content
// --------------------------------------------------

:host(.segment-content-disabled) {
opacity: $segment-button-md-opacity-disabled;
}
1 change: 1 addition & 0 deletions core/src/components/segment-content/segment-content.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

:host {
scroll-snap-align: center;
scroll-snap-stop: always;

flex-shrink: 0;

Expand Down
15 changes: 2 additions & 13 deletions core/src/components/segment-content/segment-content.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ComponentInterface } from '@stencil/core';
import { Component, Host, Prop, h } from '@stencil/core';
import { Component, Host, h } from '@stencil/core';

@Component({
tag: 'ion-segment-content',
Expand All @@ -10,20 +10,9 @@ import { Component, Host, Prop, h } from '@stencil/core';
shadow: true,
})
export class SegmentContent implements ComponentInterface {
/**
* If `true`, the segment content will not be displayed.
*/
@Prop() disabled = false;

render() {
const { disabled } = this;

return (
<Host
class={{
'segment-content-disabled': disabled,
}}
>
<Host>
<slot></slot>
</Host>
);
Expand Down
4 changes: 4 additions & 0 deletions core/src/components/segment-view/segment-view-interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface SegmentViewScrollEvent {
scrollRatio: number;
isManualScroll: boolean;
}
4 changes: 4 additions & 0 deletions core/src/components/segment-view/segment-view.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@
touch-action: none;
overflow-x: hidden;
}

:host(.segment-view-scroll-disabled) {
pointer-events: none;
}
105 changes: 30 additions & 75 deletions core/src/components/segment-view/segment-view.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Listen, Method, Prop, h } from '@stencil/core';
import { Component, Element, Event, Host, Listen, Method, Prop, State, h } from '@stencil/core';

import type { SegmentViewScrollEvent } from './segment-view-interface';

@Component({
tag: 'ion-segment-view',
Expand All @@ -10,8 +12,6 @@ import { Component, Element, Event, Host, Listen, Method, Prop, h } from '@stenc
shadow: true,
})
export class SegmentView implements ComponentInterface {
private initialScrollLeft?: number;
private previousScrollLeft = 0;
private scrollEndTimeout: ReturnType<typeof setTimeout> | null = null;
private isTouching = false;

Expand All @@ -22,65 +22,37 @@ export class SegmentView implements ComponentInterface {
*/
@Prop() disabled = false;

/**
* @internal
*
* If `true`, the segment view is scrollable.
* If `false`, pointer events will be disabled. This is to prevent issues with
* quickly scrolling after interacting with a segment button.
*/
@State() isManualScroll?: boolean;

/**
* Emitted when the segment view is scrolled.
*/
@Event() ionSegmentViewScroll!: EventEmitter<{
scrollDirection: string;
scrollDistance: number;
scrollDistancePercentage: number;
}>;
@Event() ionSegmentViewScroll!: EventEmitter<SegmentViewScrollEvent>;

/**
* Emitted when the segment view scroll has ended.
*/
@Event() ionSegmentViewScrollEnd!: EventEmitter<{ activeContentId: string }>;
@Event() ionSegmentViewScrollEnd!: EventEmitter<void>;

@Event() ionSegmentViewScrollStart!: EventEmitter<void>;

private activeContentId = '';

@Listen('scroll')
handleScroll(ev: Event) {
const { initialScrollLeft, previousScrollLeft } = this;
const { scrollLeft, offsetWidth } = ev.target as HTMLElement;

// Set initial scroll position if it's undefined
this.initialScrollLeft = initialScrollLeft ?? scrollLeft;

// Determine the scroll direction based on the previous scroll position
const scrollDirection = scrollLeft > previousScrollLeft ? 'right' : 'left';
this.previousScrollLeft = scrollLeft;
const { scrollLeft, scrollWidth, clientWidth } = ev.target as HTMLElement;
const scrollRatio = scrollLeft / (scrollWidth - clientWidth);

// Calculate the distance scrolled based on the initial scroll position
// and then transform it to a percentage of the segment view width
const scrollDistance = scrollLeft - this.initialScrollLeft;
const scrollDistancePercentage = Math.abs(scrollDistance) / offsetWidth;

// Emit the scroll direction and distance
this.ionSegmentViewScroll.emit({
scrollDirection,
scrollDistance,
scrollDistancePercentage,
scrollRatio,
isManualScroll: this.isManualScroll ?? true,
});

// Check if the scroll is at a snapping point and return if not
const atSnappingPoint = scrollLeft % offsetWidth === 0;
if (!atSnappingPoint) return;

// Find the current segment content based on the scroll position
const currentIndex = Math.round(scrollLeft / offsetWidth);

// Recursively search for the next enabled content in the scroll direction
const segmentContent = this.getNextEnabledContent(currentIndex, scrollDirection);

// Exit if no valid segment content found
if (!segmentContent) return;

// Update active content ID and scroll to the segment content
this.activeContentId = segmentContent.id;
this.setContent(segmentContent.id);

// Reset the timeout to check for scroll end
this.resetScrollEndTimeout();
}
Expand Down Expand Up @@ -118,7 +90,7 @@ export class SegmentView implements ComponentInterface {
}
this.scrollEndTimeout = setTimeout(() => {
this.checkForScrollEnd();
}, 150);
}, 50);
}

/**
Expand All @@ -127,20 +99,21 @@ export class SegmentView implements ComponentInterface {
* reset the scroll position and emit the scroll end event.
*/
private checkForScrollEnd() {
const activeContent = this.getSegmentContents().find(content => content.id === this.activeContentId);

// Only emit scroll end event if the active content is not disabled and
// the user is not touching the segment view
if (activeContent?.disabled === false && !this.isTouching) {
this.ionSegmentViewScrollEnd.emit({ activeContentId: this.activeContentId });
this.initialScrollLeft = undefined;
if (!this.isTouching) {
this.ionSegmentViewScrollEnd.emit();
this.isManualScroll = undefined;
}
}

/**
* @internal
*
* This method is used to programmatically set the displayed segment content
* in the segment view. Calling this method will update the `value` of the
* corresponding segment button.
*
* @param id: The id of the segment content to display.
* @param smoothScroll: Whether to animate the scroll transition.
*/
Expand All @@ -151,6 +124,9 @@ export class SegmentView implements ComponentInterface {

if (index === -1) return;

this.isManualScroll = false;
this.resetScrollEndTimeout();

const contentWidth = this.el.offsetWidth;
this.el.scrollTo({
top: 0,
Expand All @@ -163,35 +139,14 @@ export class SegmentView implements ComponentInterface {
return Array.from(this.el.querySelectorAll('ion-segment-content'));
}

/**
* Recursively find the next enabled segment content based on the scroll direction.
* If no enabled content is found, it will return null.
*/
private getNextEnabledContent(index: number, direction: string): HTMLIonSegmentContentElement | null {
const contents = this.getSegmentContents();

// Stop if we reach the beginning or end of the content array
if (index < 0 || index >= contents.length) return null;

const segmentContent = contents[index];

// If the content is not disabled, return it
if (!segmentContent.disabled) {
return segmentContent;
}

// Otherwise, keep searching in the same direction
const nextIndex = direction === 'right' ? index + 1 : index - 1;
return this.getNextEnabledContent(nextIndex, direction);
}

render() {
const { disabled } = this;
const { disabled, isManualScroll } = this;

return (
<Host
class={{
'segment-view-disabled': disabled,
'segment-view-scroll-disabled': isManualScroll === false,
}}
>
<slot></slot>
Expand Down
Loading
Loading