diff --git a/core/api.txt b/core/api.txt index e4e1ca4d603..43e1b6b7192 100644 --- a/core/api.txt +++ b/core/api.txt @@ -2400,6 +2400,7 @@ ion-tab,method,setActive,setActive() => Promise ion-tab-bar,shadow ion-tab-bar,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record | undefined,undefined,false,true ion-tab-bar,prop,expand,"compact" | "full",'full',false,false +ion-tab-bar,prop,hideOnScroll,boolean,false,false,false ion-tab-bar,prop,mode,"ios" | "md",undefined,false,false ion-tab-bar,prop,selectedTab,string | undefined,undefined,false,false ion-tab-bar,prop,shape,"rectangular" | "round" | "soft" | undefined,undefined,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index ecbdb7475c6..f5e9071baac 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -3970,6 +3970,11 @@ export namespace Components { * @default 'full' */ "expand": 'compact' | 'full'; + /** + * If `true`, the tab bar will be hidden when the user scrolls down and shown when the user scrolls up. Only applies when the theme is `"ionic"` and `expand` is `"compact"`. + * @default false + */ + "hideOnScroll": boolean; /** * The mode determines the platform behaviors of the component. */ @@ -10057,6 +10062,11 @@ declare namespace LocalJSX { * @default 'full' */ "expand"?: 'compact' | 'full'; + /** + * If `true`, the tab bar will be hidden when the user scrolls down and shown when the user scrolls up. Only applies when the theme is `"ionic"` and `expand` is `"compact"`. + * @default false + */ + "hideOnScroll"?: boolean; /** * The mode determines the platform behaviors of the component. */ @@ -11298,6 +11308,7 @@ declare namespace LocalJSX { interface IonTabBarAttributes { "color": Color; "selectedTab": string; + "hideOnScroll": boolean; "translucent": boolean; "expand": 'compact' | 'full'; "shape": 'soft' | 'round' | 'rectangular'; diff --git a/core/src/components/tab-bar/tab-bar.ionic.scss b/core/src/components/tab-bar/tab-bar.ionic.scss index 1253e49c334..4e384416d25 100644 --- a/core/src/components/tab-bar/tab-bar.ionic.scss +++ b/core/src/components/tab-bar/tab-bar.ionic.scss @@ -70,10 +70,15 @@ position: absolute; + // stylelint-disable-next-line property-disallowed-list + left: 50%; + align-self: center; width: fit-content; + transform: translateX(calc(-50%)); + contain: content; } @@ -85,6 +90,23 @@ bottom: calc(globals.$ion-space-400 + var(--ion-safe-area-bottom, 0)); } +// Tab Bar Hide on Scroll +// -------------------------------------------------- + +:host(.tab-bar-hide-on-scroll) { + transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1); +} + +:host(.tab-bar-scroll-hidden) { + transform: translateY(calc(100% + var(--ion-safe-area-bottom, 0) + globals.$ion-space-1000)) translateX(calc(-50%)); + + transition: transform 0.35s cubic-bezier(0.3, 1, 0.1, 1); +} + +:host([slot="top"].tab-bar-scroll-hidden) { + transform: translateY(-100%) translateX(calc(-50%)); +} + // Tab Bar Translucent // -------------------------------------------------- diff --git a/core/src/components/tab-bar/tab-bar.tsx b/core/src/components/tab-bar/tab-bar.tsx index c29ac90f08a..8fc1e5ecdd7 100644 --- a/core/src/components/tab-bar/tab-bar.tsx +++ b/core/src/components/tab-bar/tab-bar.tsx @@ -1,5 +1,6 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core'; +import { Component, Element, Event, Host, Prop, State, Watch, h, readTask, writeTask } from '@stencil/core'; +import { findIonContent, getScrollElement } from '@utils/content'; import type { KeyboardController } from '@utils/keyboard/keyboard-controller'; import { createKeyboardController } from '@utils/keyboard/keyboard-controller'; import { createColorClasses } from '@utils/theme'; @@ -26,11 +27,17 @@ export class TabBar implements ComponentInterface { private keyboardCtrl: KeyboardController | null = null; private keyboardCtrlPromise: Promise | null = null; private didLoad = false; + private scrollEl?: HTMLElement; + private contentScrollCallback?: () => void; + private lastScrollTop = 0; + private scrollDirectionChangeTop = 0; @Element() el!: HTMLElement; @State() keyboardVisible = false; + @State() scrollHidden = false; + /** * The color to use from your application's color palette. * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. @@ -57,6 +64,13 @@ export class TabBar implements ComponentInterface { } } + /** + * If `true`, the tab bar will be hidden when the user scrolls down + * and shown when the user scrolls up. + * Only applies when the theme is `"ionic"` and `expand` is `"compact"`. + */ + @Prop() hideOnScroll = false; + /** * If `true`, the tab bar will be translucent. * Only applies when the theme is `"ios"` and the device supports @@ -108,6 +122,8 @@ export class TabBar implements ComponentInterface { tab: this.selectedTab, }); } + + this.setupHideOnScroll(); } async connectedCallback() { @@ -150,6 +166,80 @@ export class TabBar implements ComponentInterface { this.keyboardCtrl.destroy(); this.keyboardCtrl = null; } + + this.destroyHideOnScroll(); + } + + private setupHideOnScroll() { + const theme = getIonTheme(this); + if (theme !== 'ionic' || !this.hideOnScroll || this.expand !== 'compact') { + return; + } + + const footerEl = this.el.closest('ion-footer'); + const pageEl = footerEl?.closest('ion-page, .ion-page') ?? this.el.closest('ion-page, .ion-page'); + const contentEl = pageEl ? findIonContent(pageEl) : null; + + if (!contentEl) { + return; + } + + this.initScrollListener(contentEl); + } + + private async initScrollListener(contentEl: HTMLElement) { + const scrollEl = (this.scrollEl = await getScrollElement(contentEl)); + + this.contentScrollCallback = () => { + readTask(() => { + const scrollTop = scrollEl.scrollTop; + const shouldHide = this.checkScrollStatus(scrollTop); + + if (shouldHide !== this.scrollHidden) { + writeTask(() => { + this.scrollHidden = shouldHide; + }); + } + + this.lastScrollTop = scrollTop; + }); + }; + + scrollEl.addEventListener('scroll', this.contentScrollCallback, { passive: true }); + } + + private destroyHideOnScroll() { + if (this.scrollEl && this.contentScrollCallback) { + this.scrollEl.removeEventListener('scroll', this.contentScrollCallback); + this.contentScrollCallback = undefined; + } + } + + private checkScrollStatus(scrollTop: number): boolean { + // Always visible within the first 80px of scroll + const visibleZone = 80; + // Hides after 60px of continuous downward scrolling only, when scrolling up threashold should be 0px + const scrollThresholdHide = 60; + + if (scrollTop <= visibleZone) { + return false; + } + + const isScrollingDown = scrollTop > this.lastScrollTop; + const wasScrollingDown = this.lastScrollTop > this.scrollDirectionChangeTop; + + if (isScrollingDown !== wasScrollingDown) { + this.scrollDirectionChangeTop = this.lastScrollTop; + } + + const delta = Math.abs(scrollTop - this.scrollDirectionChangeTop); + const threshold = isScrollingDown ? scrollThresholdHide : 0; + + if (delta < threshold) { + return this.scrollHidden; + } + + return isScrollingDown; } private getShape(): string | undefined { @@ -169,7 +259,7 @@ export class TabBar implements ComponentInterface { } render() { - const { color, translucent, keyboardVisible, expand } = this; + const { color, translucent, keyboardVisible, scrollHidden, expand, hideOnScroll } = this; const theme = getIonTheme(this); const shape = this.getShape(); const shouldHide = keyboardVisible && this.el.getAttribute('slot') !== 'top'; @@ -182,6 +272,8 @@ export class TabBar implements ComponentInterface { [theme]: true, 'tab-bar-translucent': translucent, 'tab-bar-hidden': shouldHide, + 'tab-bar-hide-on-scroll': hideOnScroll, + 'tab-bar-scroll-hidden': scrollHidden, [`tab-bar-${expand}`]: true, [`tab-bar-${shape}`]: shape !== undefined, })} diff --git a/core/src/components/tab-bar/test/expand/tab-bar.e2e.ts-snapshots/tab-bar-expand-compact-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/tab-bar/test/expand/tab-bar.e2e.ts-snapshots/tab-bar-expand-compact-ionic-md-ltr-light-Mobile-Chrome-linux.png index c4e13cf4adf..c57299ddb09 100644 Binary files a/core/src/components/tab-bar/test/expand/tab-bar.e2e.ts-snapshots/tab-bar-expand-compact-ionic-md-ltr-light-Mobile-Chrome-linux.png and b/core/src/components/tab-bar/test/expand/tab-bar.e2e.ts-snapshots/tab-bar-expand-compact-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/tab-bar/test/expand/tab-bar.e2e.ts-snapshots/tab-bar-expand-compact-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/tab-bar/test/expand/tab-bar.e2e.ts-snapshots/tab-bar-expand-compact-ionic-md-ltr-light-Mobile-Firefox-linux.png index 87b1455219d..422536fbfa1 100644 Binary files a/core/src/components/tab-bar/test/expand/tab-bar.e2e.ts-snapshots/tab-bar-expand-compact-ionic-md-ltr-light-Mobile-Firefox-linux.png and b/core/src/components/tab-bar/test/expand/tab-bar.e2e.ts-snapshots/tab-bar-expand-compact-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/tab-bar/test/expand/tab-bar.e2e.ts-snapshots/tab-bar-expand-compact-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/tab-bar/test/expand/tab-bar.e2e.ts-snapshots/tab-bar-expand-compact-ionic-md-ltr-light-Mobile-Safari-linux.png index 4655b303eb4..7df36dc9be5 100644 Binary files a/core/src/components/tab-bar/test/expand/tab-bar.e2e.ts-snapshots/tab-bar-expand-compact-ionic-md-ltr-light-Mobile-Safari-linux.png and b/core/src/components/tab-bar/test/expand/tab-bar.e2e.ts-snapshots/tab-bar-expand-compact-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/tab-bar/test/hide-on-scroll/index.html b/core/src/components/tab-bar/test/hide-on-scroll/index.html new file mode 100644 index 00000000000..7e75f033507 --- /dev/null +++ b/core/src/components/tab-bar/test/hide-on-scroll/index.html @@ -0,0 +1,74 @@ + + + + + Tab Bar - Hide on Scroll + + + + + + + + + + + +
+ +
+

Scroll down to hide the tab bar, scroll up to show it.

+

Requirements:

+
    +
  • Theme must be set to ionic
  • +
  • expand must be set to compact
  • +
  • A sibling ion-content must exist for scroll detection
  • +
+
+
+ + + + + + Home + + + + + Favorites + + + + + Search + + + + + Settings + + + +
+
+
+ + + + diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index fecc4aed2a8..af9d5e35525 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -2370,14 +2370,14 @@ export declare interface IonTab extends Components.IonTab {} @ProxyCmp({ - inputs: ['color', 'expand', 'mode', 'selectedTab', 'shape', 'theme', 'translucent'] + inputs: ['color', 'expand', 'hideOnScroll', 'mode', 'selectedTab', 'shape', 'theme', 'translucent'] }) @Component({ selector: 'ion-tab-bar', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['color', 'expand', 'mode', 'selectedTab', 'shape', 'theme', 'translucent'], + inputs: ['color', 'expand', 'hideOnScroll', 'mode', 'selectedTab', 'shape', 'theme', 'translucent'], }) export class IonTabBar { protected el: HTMLIonTabBarElement; diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index 41e27889758..3bac5ccbe72 100644 --- a/packages/angular/standalone/src/directives/proxies.ts +++ b/packages/angular/standalone/src/directives/proxies.ts @@ -2113,14 +2113,14 @@ export declare interface IonTab extends Components.IonTab {} @ProxyCmp({ defineCustomElementFn: defineIonTabBar, - inputs: ['color', 'expand', 'mode', 'selectedTab', 'shape', 'theme', 'translucent'] + inputs: ['color', 'expand', 'hideOnScroll', 'mode', 'selectedTab', 'shape', 'theme', 'translucent'] }) @Component({ selector: 'ion-tab-bar', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['color', 'expand', 'mode', 'selectedTab', 'shape', 'theme', 'translucent'], + inputs: ['color', 'expand', 'hideOnScroll', 'mode', 'selectedTab', 'shape', 'theme', 'translucent'], standalone: true }) export class IonTabBar {