support translation & width scaling

This commit is contained in:
Tanner Reits
2024-10-11 12:47:19 -04:00
parent 475de8b6c7
commit b8dd17eae7
10 changed files with 87 additions and 77 deletions

View File

@ -2698,6 +2698,7 @@ export namespace Components {
* If `true`, the user cannot interact with the segment button. * If `true`, the user cannot interact with the segment button.
*/ */
"disabled": boolean; "disabled": boolean;
"hasIndicator": boolean;
/** /**
* Set the layout of the text and icon in the segment. * Set the layout of the text and icon in the segment.
*/ */
@ -7512,6 +7513,7 @@ declare namespace LocalJSX {
* If `true`, the user cannot interact with the segment button. * If `true`, the user cannot interact with the segment button.
*/ */
"disabled"?: boolean; "disabled"?: boolean;
"hasIndicator"?: boolean;
/** /**
* Set the layout of the text and icon in the segment. * Set the layout of the text and icon in the segment.
*/ */

View File

@ -65,6 +65,8 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
this.updateState(); this.updateState();
} }
@Prop() hasIndicator = true;
connectedCallback() { connectedCallback() {
const segmentEl = (this.segmentEl = this.el.closest('ion-segment')); const segmentEl = (this.segmentEl = this.el.closest('ion-segment'));
if (segmentEl) { if (segmentEl) {
@ -187,9 +189,11 @@ export class SegmentButton implements ComponentInterface, ButtonInterface {
</span> </span>
{mode === 'md' && <ion-ripple-effect></ion-ripple-effect>} {mode === 'md' && <ion-ripple-effect></ion-ripple-effect>}
</button> </button>
<div part="indicator" class="segment-button-indicator segment-button-indicator-animated"> {this.hasIndicator && (
<div part="indicator-background" class="segment-button-indicator-background"></div> <div part="indicator" class="segment-button-indicator segment-button-indicator-animated">
</div> <div part="indicator-background" class="segment-button-indicator-background"></div>
</div>
)}
</Host> </Host>
); );
} }

View File

@ -3,6 +3,7 @@
:host { :host {
scroll-snap-align: center; scroll-snap-align: center;
scroll-snap-stop: always;
flex-shrink: 0; flex-shrink: 0;

View File

@ -9,6 +9,8 @@
overflow-x: scroll; overflow-x: scroll;
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
scroll-behavior: smooth;
/* Hide scrollbar in Firefox */ /* Hide scrollbar in Firefox */
scrollbar-width: none; scrollbar-width: none;

View File

@ -127,7 +127,7 @@ export class SegmentView implements ComponentInterface {
* reset the scroll position and emit the scroll end event. * reset the scroll position and emit the scroll end event.
*/ */
private checkForScrollEnd() { private checkForScrollEnd() {
const activeContent = this.getSegmentContents().find(content => content.id === this.activeContentId); const activeContent = this.getSegmentContents().find((content) => content.id === this.activeContentId);
// Only emit scroll end event if the active content is not disabled and // Only emit scroll end event if the active content is not disabled and
// the user is not touching the segment view // the user is not touching the segment view

View File

@ -31,8 +31,19 @@
contain: paint; contain: paint;
user-select: none; user-select: none;
}
.segment-indicator {
@include transform-origin(left);
position: absolute;
height: 100%;
div {
background: red;
height: 100%;
}
}
}
// Segment: Scrollable // Segment: Scrollable
// -------------------------------------------------- // --------------------------------------------------

View File

@ -156,6 +156,10 @@ export class Segment implements ComponentInterface {
this.emitStyle(); this.emitStyle();
this.segmentViewEl = this.getSegmentView(); this.segmentViewEl = this.getSegmentView();
if (this.segmentViewEl) {
this.getButtons().forEach((ref) => (ref.hasIndicator = false));
}
} }
disconnectedCallback() { disconnectedCallback() {
@ -202,6 +206,28 @@ export class Segment implements ComponentInterface {
// Update segment view based on the initial value, // Update segment view based on the initial value,
// but do not animate the scroll // but do not animate the scroll
this.updateSegmentView(false); this.updateSegmentView(false);
// TODO: this isn't always consistent, button width can sometimes be 0
if (this.segmentViewEl) {
const buttons = this.getButtons();
const activeButtonIndex = buttons.findIndex((ref) => ref.value === this.value);
if (activeButtonIndex >= 0) {
const activeButtonStyles = buttons[activeButtonIndex].getBoundingClientRect();
const indicator = this.el.shadowRoot!.querySelector('.segment-indicator') as HTMLDivElement | null;
if (indicator) {
const startingX = buttons
.slice(0, activeButtonIndex)
.reduce((acc, ref) => acc + ref.getBoundingClientRect().width, 0);
indicator.style.width = `${activeButtonStyles.width}px`;
indicator.style.left = `${startingX}px`;
// setTimeout(() => {
// indicator.style.transition = 'left 0.3s linear, width 0.3s linear';
// });
}
}
}
} }
onStart(detail: GestureDetail) { onStart(detail: GestureDetail) {
@ -364,62 +390,36 @@ export class Segment implements ComponentInterface {
// Only update the indicator if the event was dispatched from the correct segment view // Only update the indicator if the event was dispatched from the correct segment view
if (ev.composedPath().includes(segmentViewEl) || dispatchedFrom?.contains(segmentEl)) { if (ev.composedPath().includes(segmentViewEl) || dispatchedFrom?.contains(segmentEl)) {
const buttons = this.getButtons(); const buttons = this.getButtons();
const currentIndex = buttons.findIndex((button) => button.value === this.value);
const currentButton = buttons[currentIndex];
const indicator = this.el.shadowRoot!.querySelector('.segment-indicator') as HTMLDivElement | null;
// If no buttons are found or there is no value set then do nothing // If no buttons are found or there is no value set then do nothing
if (!buttons.length || this.value === undefined) return; if (!buttons.length || this.value === undefined || !indicator) return;
const index = buttons.findIndex((button) => button.value === this.value);
const current = buttons[index];
const indicatorEl = this.getIndicator(current);
this.scrolledIndicator = indicatorEl;
const { scrollDistancePercentage, scrollDistance } = ev.detail; const { scrollDistancePercentage, scrollDistance } = ev.detail;
if (indicatorEl && !isNaN(scrollDistancePercentage)) { const nextIndex = scrollDistance > 0 ? currentIndex + 1 : currentIndex - 1;
indicatorEl.style.transition = 'transform 0.3s ease-out'; if (nextIndex >= 0 && nextIndex < buttons.length) {
const nextButton = buttons[nextIndex];
const nextButtonWidth = nextButton.getBoundingClientRect().width;
// Calculate the amount the indicator should move based on the scroll percentage // Scale the width based on the width of the next button
// and the width of the current button const diff = nextButtonWidth - currentButton.getBoundingClientRect().width;
const scrollAmount = scrollDistancePercentage * current.getBoundingClientRect().width; const width = currentButton.getBoundingClientRect().width + diff * scrollDistancePercentage;
const transformValue = scrollDistance < 0 ? -scrollAmount : scrollAmount; indicator.style.width = `${width}px`;
// Calculate total width of buttons to the left of the current button // Translate the indicator based on the scroll distance
const totalButtonWidthBefore = buttons const distanceToNextButton = buttons
.slice(0, index) .slice(0, nextIndex)
.reduce((acc, button) => acc + button.getBoundingClientRect().width, 0); .reduce((acc, ref) => acc + ref.getBoundingClientRect().width, 0);
const distanceToCurrentButton = buttons
// Calculate total width of buttons to the right of the current button .slice(0, currentIndex)
const totalButtonWidthAfter = buttons .reduce((acc, ref) => acc + ref.getBoundingClientRect().width, 0);
.slice(index + 1) indicator.style.left =
.reduce((acc, button) => acc + button.getBoundingClientRect().width, 0); scrollDistance > 0
? `${distanceToCurrentButton + currentButton.getBoundingClientRect().width * scrollDistancePercentage}px`
// Set minTransform and maxTransform : `${distanceToNextButton + nextButtonWidth - nextButtonWidth * scrollDistancePercentage}px`;
const minTransform = -totalButtonWidthBefore;
const maxTransform = totalButtonWidthAfter;
// Clamp the transform value to ensure it doesn't go out of bounds
const clampedTransform = Math.max(minTransform, Math.min(transformValue, maxTransform));
// Apply the clamped transform value to the indicator element
const transform = `translate3d(${clampedTransform}px, 0, 0)`;
indicatorEl.style.setProperty('transform', transform);
// Scroll the buttons if the indicator is out of view
const indicatorX = indicatorEl.getBoundingClientRect().x;
const buttonWidth = current.getBoundingClientRect().width;
if (scrollDistance < 0 && indicatorX < 0) {
this.el.scrollBy({
top: 0,
left: indicatorX,
behavior: 'instant',
});
} else if (scrollDistance > 0 && indicatorX + buttonWidth > this.el.offsetWidth) {
this.el.scrollBy({
top: 0,
left: indicatorX + buttonWidth - this.el.offsetWidth,
behavior: 'instant',
});
}
} }
} }
} }
@ -442,25 +442,9 @@ export class Segment implements ComponentInterface {
const segmentEl = this.el; const segmentEl = this.el;
if (ev.composedPath().includes(segmentViewEl) || dispatchedFrom?.contains(segmentEl)) { if (ev.composedPath().includes(segmentViewEl) || dispatchedFrom?.contains(segmentEl)) {
if (this.scrolledIndicator) {
const computedStyle = window.getComputedStyle(this.scrolledIndicator);
const isTransitioning = computedStyle.transitionDuration !== '0s';
if (isTransitioning) {
// Add a transitionend listener if the indicator is transitioning
this.waitForTransitionEnd(this.scrolledIndicator, () => {
this.updateValueAfterTransition(ev.detail.activeContentId);
});
} else {
// Immediately update the value if there's no transition
this.updateValueAfterTransition(ev.detail.activeContentId);
}
} else {
// Immediately update the value if there's no indicator
this.updateValueAfterTransition(ev.detail.activeContentId);
}
this.isScrolling = false; this.isScrolling = false;
this.value = ev.detail.activeContentId;
} }
} }
@ -771,6 +755,11 @@ export class Segment implements ComponentInterface {
'segment-scrollable': this.scrollable, 'segment-scrollable': this.scrollable,
})} })}
> >
{this.segmentViewEl && (
<div part="indicator" class="segment-indicator">
<div part="indicator-background"></div>
</div>
)}
<slot onSlotchange={this.onSlottedItemsChange}></slot> <slot onSlotchange={this.onSlottedItemsChange}></slot>
</Host> </Host>
); );

View File

@ -1984,14 +1984,14 @@ This event will not emit when programmatically setting the `value` property.
@ProxyCmp({ @ProxyCmp({
inputs: ['contentId', 'disabled', 'layout', 'mode', 'type', 'value'] inputs: ['contentId', 'disabled', 'hasIndicator', 'layout', 'mode', 'type', 'value']
}) })
@Component({ @Component({
selector: 'ion-segment-button', selector: 'ion-segment-button',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>', template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['contentId', 'disabled', 'layout', 'mode', 'type', 'value'], inputs: ['contentId', 'disabled', 'hasIndicator', 'layout', 'mode', 'type', 'value'],
}) })
export class IonSegmentButton { export class IonSegmentButton {
protected el: HTMLElement; protected el: HTMLElement;

View File

@ -1818,14 +1818,14 @@ export declare interface IonRow extends Components.IonRow {}
@ProxyCmp({ @ProxyCmp({
defineCustomElementFn: defineIonSegmentButton, defineCustomElementFn: defineIonSegmentButton,
inputs: ['contentId', 'disabled', 'layout', 'mode', 'type', 'value'] inputs: ['contentId', 'disabled', 'hasIndicator', 'layout', 'mode', 'type', 'value']
}) })
@Component({ @Component({
selector: 'ion-segment-button', selector: 'ion-segment-button',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>', template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['contentId', 'disabled', 'layout', 'mode', 'type', 'value'], inputs: ['contentId', 'disabled', 'hasIndicator', 'layout', 'mode', 'type', 'value'],
standalone: true standalone: true
}) })
export class IonSegmentButton { export class IonSegmentButton {

View File

@ -750,7 +750,8 @@ export const IonSegmentButton = /*@__PURE__*/ defineContainer<JSX.IonSegmentButt
'disabled', 'disabled',
'layout', 'layout',
'type', 'type',
'value' 'value',
'hasIndicator'
], ],
'value', 'ion-change'); 'value', 'ion-change');