Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2400,6 +2400,7 @@ ion-tab,method,setActive,setActive() => Promise<void>
ion-tab-bar,shadow
ion-tab-bar,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record<never, never> | 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
Expand Down
11 changes: 11 additions & 0 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -11298,6 +11308,7 @@ declare namespace LocalJSX {
interface IonTabBarAttributes {
"color": Color;
"selectedTab": string;
"hideOnScroll": boolean;
"translucent": boolean;
"expand": 'compact' | 'full';
"shape": 'soft' | 'round' | 'rectangular';
Expand Down
22 changes: 22 additions & 0 deletions core/src/components/tab-bar/tab-bar.ionic.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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
// --------------------------------------------------

Expand Down
96 changes: 94 additions & 2 deletions core/src/components/tab-bar/tab-bar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -26,11 +27,17 @@ export class TabBar implements ComponentInterface {
private keyboardCtrl: KeyboardController | null = null;
private keyboardCtrlPromise: Promise<KeyboardController> | 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"`.
Expand All @@ -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
Expand Down Expand Up @@ -108,6 +122,8 @@ export class TabBar implements ComponentInterface {
tab: this.selectedTab,
});
}

this.setupHideOnScroll();
}

async connectedCallback() {
Expand Down Expand Up @@ -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 {
Expand All @@ -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';
Expand All @@ -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,
})}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
74 changes: 74 additions & 0 deletions core/src/components/tab-bar/test/hide-on-scroll/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en" dir="ltr" mode="ionic">
<head>
<meta charset="UTF-8" />
<title>Tab Bar - Hide on Scroll</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<link rel="stylesheet" href="../../../../../css/ionic.bundle.css" />
<link rel="stylesheet" href="../../../../../scripts/testing/styles.css" />
</head>

<body>
<ion-app>
<ion-tabs>
<div class="ion-page">
<ion-content>
<div class="spacer">
<p>Scroll down to hide the tab bar, scroll up to show it.</p>
<p><strong>Requirements:</strong></p>
<ul>
<li>Theme must be set to <code>ionic</code></li>
<li><code>expand</code> must be set to <code>compact</code></li>
<li>A sibling <code>ion-content</code> must exist for scroll detection</li>
</ul>
</div>
</ion-content>

<ion-footer>
<ion-tab-bar slot="bottom" hide-on-scroll="true" selected-tab="1" expand="compact">
<ion-tab-button tab="1">
<ion-icon name="home-outline"></ion-icon>
<ion-label>Home</ion-label>
</ion-tab-button>

<ion-tab-button tab="2">
<ion-icon name="heart-outline"></ion-icon>
<ion-label>Favorites</ion-label>
</ion-tab-button>

<ion-tab-button tab="3">
<ion-icon name="search-outline"></ion-icon>
<ion-label>Search</ion-label>
</ion-tab-button>

<ion-tab-button tab="4">
<ion-icon name="settings-outline"></ion-icon>
<ion-label>Settings</ion-label>
</ion-tab-button>
</ion-tab-bar>
</ion-footer>
</div>
</ion-tabs>
</ion-app>

<style>
.spacer {
padding: 16px;
height: 2000px;
background: linear-gradient(to bottom, #e0f7fa, #80deea, #26c6da, #00acc1, #00838f);
}

.spacer p {
margin-bottom: 12px;
font-size: 16px;
color: #333;
}
</style>
</body>
</html>
4 changes: 2 additions & 2 deletions packages/angular/src/directives/proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@joselrio why only angular needed this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is am auto-generated file after npm run build, however here's why this is needed:
Inputs array exists in Angular because Angular requires explicit declarations of which properties a component accepts — it's a fundamental part of how Angular's compiler and change detection work, unlike React and Vue which pass props through more transparently.

})
@Component({
selector: 'ion-tab-bar',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// 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;
Expand Down
4 changes: 2 additions & 2 deletions packages/angular/standalone/src/directives/proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<ng-content></ng-content>',
// 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 {
Expand Down
Loading