Skip to content

Commit 11ea3b3

Browse files
refactor(header,footer,tab-bar): extract scroll-hide logic into shared controller
1 parent 9ad2c05 commit 11ea3b3

10 files changed

Lines changed: 259 additions & 402 deletions

File tree

core/src/components.d.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,7 +1462,7 @@ export namespace Components {
14621462
}
14631463
interface IonFooter {
14641464
/**
1465-
* Describes the scroll effect that will be applied to the footer. Only applies when the mode is `"ios"`.
1465+
* Describes the scroll effect that will be applied to the footer. Only applies when the theme is `"ios"`.
14661466
* @deprecated Use `scrollEffect` instead.
14671467
*/
14681468
"collapse"?: 'fade';
@@ -1544,7 +1544,7 @@ export namespace Components {
15441544
}
15451545
interface IonHeader {
15461546
/**
1547-
* Describes the scroll effect that will be applied to the header. Only applies when the mode is `"ios"`. Typically used for [Collapsible Large Titles](https://ionicframework.com/docs/api/title#collapsible-large-titles)
1547+
* Describes the scroll effect that will be applied to the header. Only applies when the theme is `"ios"`. Typically used for [Collapsible Large Titles](https://ionicframework.com/docs/api/title#collapsible-large-titles)
15481548
* @deprecated Use `scrollEffect` instead.
15491549
*/
15501550
"collapse"?: 'condense' | 'fade';
@@ -7548,7 +7548,7 @@ declare namespace LocalJSX {
75487548
}
75497549
interface IonFooter {
75507550
/**
7551-
* Describes the scroll effect that will be applied to the footer. Only applies when the mode is `"ios"`.
7551+
* Describes the scroll effect that will be applied to the footer. Only applies when the theme is `"ios"`.
75527552
* @deprecated Use `scrollEffect` instead.
75537553
*/
75547554
"collapse"?: 'fade';
@@ -7626,7 +7626,7 @@ declare namespace LocalJSX {
76267626
}
76277627
interface IonHeader {
76287628
/**
7629-
* Describes the scroll effect that will be applied to the header. Only applies when the mode is `"ios"`. Typically used for [Collapsible Large Titles](https://ionicframework.com/docs/api/title#collapsible-large-titles)
7629+
* Describes the scroll effect that will be applied to the header. Only applies when the theme is `"ios"`. Typically used for [Collapsible Large Titles](https://ionicframework.com/docs/api/title#collapsible-large-titles)
76307630
* @deprecated Use `scrollEffect` instead.
76317631
*/
76327632
"collapse"?: 'condense' | 'fade';

core/src/components/content/content.scss

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,8 @@
276276
}
277277

278278
:host(.content-header-hide-scroll-partner:not(.content-fullscreen).content-header-hide-scroll-hidden) .inner-scroll {
279-
transform: translateY(calc(-1 * var(--internal-header-hide-slide-y, 0px)));
280-
height: calc(100% + var(--internal-header-hide-slide-y, 0px));
279+
transform: translateY(calc(-1 * var(--internal-header-hide-height, 0px)));
280+
height: calc(100% + var(--internal-header-hide-height, 0px));
281281
transition: transform 200ms cubic-bezier(0.4, 0, 1, 1),
282282
height 200ms cubic-bezier(0.4, 0, 1, 1);
283283
}
@@ -288,7 +288,7 @@
288288
}
289289

290290
:host(.content-footer-hide-scroll-partner:not(.content-fullscreen).content-footer-hide-scroll-hidden) .inner-scroll {
291-
height: calc(100% + var(--internal-footer-hide-slide-y, 0px));
291+
height: calc(100% + var(--internal-footer-hide-height, 0px));
292292
transition: height 350ms cubic-bezier(0.4, 0, 1, 1);
293293
}
294294

@@ -298,7 +298,7 @@
298298
}
299299

300300
:host(.content-tab-bar-hide-scroll-partner:not(.content-fullscreen).content-tab-bar-hide-scroll-hidden) .inner-scroll {
301-
height: calc(100% + var(--internal-tab-bar-hide-slide-y, 0px));
301+
height: calc(100% + var(--internal-tab-bar-hide-height, 0px));
302302
transition: height 350ms cubic-bezier(0.4, 0, 1, 1);
303303
}
304304

core/src/components/footer/footer.scss

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ ion-footer.footer-toolbar-padding ion-toolbar:last-of-type {
2121
// Footer - Scroll Effect Hide
2222
// --------------------------------------------------
2323

24-
// Show transition — snappy spring return
24+
// Slide back into view when scrolling up — spring ease-out snaps the footer
25+
// back into place quickly and settles smoothly.
2526
ion-footer.footer-scroll-effect-hide {
2627
transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
2728
}
2829

29-
// Hidden state — slower exit feels natural moving with scroll direction
30+
// Slide out of view when scrolling down — footer moves off the bottom of the
31+
// screen. Slower duration mirrors the natural pace of the downward scroll.
3032
ion-footer.footer-scroll-effect-hide.footer-scroll-hidden {
3133
transform: translateY(100%);
3234
transition: transform 350ms cubic-bezier(0.4, 0, 1, 1);
@@ -37,4 +39,4 @@ ion-footer.footer-scroll-effect-hide.footer-scroll-hidden {
3739
ion-footer.footer-scroll-effect-hide.footer-scroll-hidden {
3840
transition: none;
3941
}
40-
}
42+
}

core/src/components/footer/footer.tsx

Lines changed: 28 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import { ION_PAGE_ELEMENT_SELECTOR, findIonContent, getScrollElement, printIonCo
44
import type { KeyboardController } from '@utils/keyboard/keyboard-controller';
55
import { createKeyboardController } from '@utils/keyboard/keyboard-controller';
66
import { printIonWarning } from '@utils/logging';
7+
import type { ScrollHideController } from '@utils/scroll-hide-controller';
8+
import { createScrollHideController } from '@utils/scroll-hide-controller';
79

8-
import { getIonMode, getIonTheme } from '../../global/ionic-global';
10+
import { getIonTheme } from '../../global/ionic-global';
911

1012
import type { FooterScrollEffect } from './footer-interface';
1113
import { handleFooterFade } from './footer.utils';
@@ -25,22 +27,12 @@ import { handleFooterFade } from './footer.utils';
2527
export class Footer implements ComponentInterface {
2628
private scrollEl?: HTMLElement;
2729
private contentScrollCallback?: () => void;
28-
private contentWheelCallback?: EventListener;
2930
private keyboardCtrl: KeyboardController | null = null;
3031
private keyboardCtrlPromise: Promise<KeyboardController> | null = null;
32+
private scrollHideCtrl?: ScrollHideController;
3133
private resizeObserver?: ResizeObserver;
3234
private contentEl?: HTMLElement;
33-
34-
// scrollEffect="hide" scroll tracking state
35-
private scrollHidden = false;
36-
private previousScrollTop = 0;
37-
private scrollTopAtDirectionChange = 0;
38-
private lastWheelEventTime = 0;
39-
private suppressShowUntil = 0;
40-
41-
private readonly TOP_VISIBLE_THRESHOLD = 80;
42-
private readonly SCROLL_HIDE_THRESHOLD = 60;
43-
private readonly WHEEL_SUPPRESS_DURATION_MS = 80;
35+
private isHidden = false;
4436

4537
@State() private keyboardVisible = false;
4638

@@ -56,7 +48,7 @@ export class Footer implements ComponentInterface {
5648

5749
/**
5850
* Describes the scroll effect that will be applied to the footer.
59-
* Only applies when the mode is `"ios"`.
51+
* Only applies when the theme is `"ios"`.
6052
*
6153
* @deprecated Use `scrollEffect` instead.
6254
*/
@@ -147,8 +139,8 @@ export class Footer implements ComponentInterface {
147139
}
148140

149141
// fade via the deprecated `collapse` prop is iOS-only.
150-
// fade via the new `scrollEffect` prop works in all modes.
151-
const isModeRestricted = scrollEffect === undefined && getIonMode(this) !== 'ios';
142+
// fade via the new `scrollEffect` prop works in all themes.
143+
const isModeRestricted = scrollEffect === undefined && getIonTheme(this) !== 'ios';
152144
if (hasFade && !isModeRestricted) {
153145
if (!contentEl) {
154146
printIonContentErrorMsg(this.el);
@@ -161,123 +153,47 @@ export class Footer implements ComponentInterface {
161153

162154
private setupScrollEffectHide = async (contentEl: HTMLElement) => {
163155
this.contentEl = contentEl;
164-
const scrollEl = (this.scrollEl = await getScrollElement(contentEl));
156+
const scrollEl = await getScrollElement(contentEl);
165157

166-
this.updateHideSlideY();
158+
this.updateHideHeight();
167159

168160
if (typeof ResizeObserver !== 'undefined') {
169-
this.resizeObserver = new ResizeObserver(() => this.updateHideSlideY());
161+
this.resizeObserver = new ResizeObserver(() => this.updateHideHeight());
170162
this.resizeObserver.observe(this.el);
171163
}
172164

173-
this.contentScrollCallback = () => this.handleScrollEffectHide();
174-
scrollEl.addEventListener('scroll', this.contentScrollCallback, { passive: true });
175-
176-
this.contentWheelCallback = (ev: Event) => this.handleWheelEffectHide(ev as WheelEvent);
177-
scrollEl.addEventListener('wheel', this.contentWheelCallback, { passive: true });
165+
this.scrollHideCtrl = createScrollHideController(scrollEl, (hidden) => this.setHidden(hidden));
178166

179167
contentEl.classList.add('content-footer-hide-scroll-partner');
180168
};
181169

182-
private updateHideSlideY() {
170+
/**
171+
* Reads the footer's current height and writes it as a CSS variable
172+
* on both the footer and the sibling content. The content uses this
173+
* value to expand its scroll area when the footer hides (gap compensation).
174+
*/
175+
private updateHideHeight() {
183176
readTask(() => {
184177
const footerHeightPx = this.el.offsetHeight;
185178
writeTask(() => {
186-
this.el.style.setProperty('--internal-footer-hide-slide-y', `${footerHeightPx}px`);
179+
this.el.style.setProperty('--internal-footer-hide-height', `${footerHeightPx}px`);
187180
if (this.contentEl) {
188-
this.contentEl.style.setProperty('--internal-footer-hide-slide-y', `${footerHeightPx}px`);
181+
this.contentEl.style.setProperty('--internal-footer-hide-height', `${footerHeightPx}px`);
189182
}
190183
});
191184
});
192185
}
193186

194-
private handleWheelEffectHide = (ev: WheelEvent) => {
195-
this.lastWheelEventTime = Date.now();
196-
197-
readTask(() => {
198-
const currentScrollTop = this.scrollEl!.scrollTop;
199-
200-
if (currentScrollTop <= this.TOP_VISIBLE_THRESHOLD) {
201-
if (this.scrollHidden) {
202-
writeTask(() => this.setHidden(false));
203-
}
204-
return;
205-
}
206-
207-
if (ev.deltaY < 0) {
208-
this.scrollTopAtDirectionChange = currentScrollTop;
209-
if (this.scrollHidden) {
210-
writeTask(() => this.setHidden(false));
211-
}
212-
} else if (ev.deltaY > 0) {
213-
const scrolledSinceDirectionChange = currentScrollTop - this.scrollTopAtDirectionChange;
214-
if (scrolledSinceDirectionChange >= this.SCROLL_HIDE_THRESHOLD && !this.scrollHidden) {
215-
writeTask(() => this.setHidden(true));
216-
}
217-
}
218-
});
219-
};
220-
221-
private handleScrollEffectHide = () => {
222-
// Suppress scroll events shortly after a wheel event — delta already processed via wheel
223-
if (Date.now() - this.lastWheelEventTime < this.WHEEL_SUPPRESS_DURATION_MS) {
224-
return;
225-
}
226-
227-
readTask(() => {
228-
const currentScrollTop = this.scrollEl!.scrollTop;
229-
230-
if (currentScrollTop <= this.TOP_VISIBLE_THRESHOLD) {
231-
if (this.scrollHidden) {
232-
writeTask(() => this.setHidden(false));
233-
}
234-
this.previousScrollTop = currentScrollTop;
235-
return;
236-
}
237-
238-
const isScrollingDown = currentScrollTop > this.previousScrollTop;
239-
const wasScrollingDown = this.previousScrollTop > this.scrollTopAtDirectionChange;
240-
241-
if (isScrollingDown !== wasScrollingDown) {
242-
this.scrollTopAtDirectionChange = this.previousScrollTop;
243-
}
244-
245-
const scrolledSinceDirectionChange = Math.abs(currentScrollTop - this.scrollTopAtDirectionChange);
246-
const requiredScrollDistance = isScrollingDown ? this.SCROLL_HIDE_THRESHOLD : 0;
247-
this.previousScrollTop = currentScrollTop;
248-
249-
if (scrolledSinceDirectionChange < requiredScrollDistance) {
250-
return;
251-
}
252-
253-
const shouldHide = isScrollingDown;
254-
if (shouldHide !== this.scrollHidden) {
255-
// After hiding, the content height increases (CSS transition), which lowers
256-
// max scrollTop and triggers a spurious upward-scroll event. Suppress "show"
257-
// actions briefly to absorb that adjustment.
258-
if (!shouldHide && Date.now() < this.suppressShowUntil) {
259-
return;
260-
}
261-
writeTask(() => this.setHidden(shouldHide));
262-
}
263-
});
264-
};
265-
266187
private setHidden(hidden: boolean) {
267-
this.scrollHidden = hidden;
188+
this.isHidden = hidden;
268189
this.el.classList.toggle('footer-scroll-hidden', hidden);
269190

270191
if (hidden) {
271192
this.el.setAttribute('inert', '');
272193
this.el.setAttribute('aria-hidden', 'true');
273-
// Suppress "show" events for slightly longer than the content height
274-
// transition (350ms) to prevent the scrollTop adjustment from immediately
275-
// re-showing the footer.
276-
this.suppressShowUntil = Date.now() + 400;
277194
} else {
278195
this.el.removeAttribute('inert');
279196
this.el.removeAttribute('aria-hidden');
280-
this.suppressShowUntil = 0;
281197
}
282198

283199
if (this.contentEl) {
@@ -305,9 +221,9 @@ export class Footer implements ComponentInterface {
305221
this.contentScrollCallback = undefined;
306222
}
307223

308-
if (this.scrollEl && this.contentWheelCallback) {
309-
this.scrollEl.removeEventListener('wheel', this.contentWheelCallback);
310-
this.contentWheelCallback = undefined;
224+
if (this.scrollHideCtrl) {
225+
this.scrollHideCtrl.destroy();
226+
this.scrollHideCtrl = undefined;
311227
}
312228

313229
if (this.resizeObserver) {
@@ -317,21 +233,17 @@ export class Footer implements ComponentInterface {
317233

318234
if (this.contentEl) {
319235
this.contentEl.classList.remove('content-footer-hide-scroll-partner', 'content-footer-hide-scroll-hidden');
320-
this.contentEl.style.removeProperty('--internal-footer-hide-slide-y');
236+
this.contentEl.style.removeProperty('--internal-footer-hide-height');
321237
this.contentEl = undefined;
322238
}
323239

324-
if (this.scrollHidden) {
240+
if (this.isHidden) {
325241
this.el.classList.remove('footer-scroll-hidden');
326242
this.el.removeAttribute('inert');
327243
this.el.removeAttribute('aria-hidden');
328-
this.scrollHidden = false;
244+
this.isHidden = false;
329245
}
330-
this.el.style.removeProperty('--internal-footer-hide-slide-y');
331-
this.previousScrollTop = 0;
332-
this.scrollTopAtDirectionChange = 0;
333-
this.lastWheelEventTime = 0;
334-
this.suppressShowUntil = 0;
246+
this.el.style.removeProperty('--internal-footer-hide-height');
335247
}
336248

337249
render() {

core/src/components/header/header.common.scss

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@ ion-header ion-toolbar:first-of-type {
1717
// Header - Scroll Effect Hide
1818
// --------------------------------------------------
1919

20-
// Show transition (returning to visible state)
20+
// Slide back into view when scrolling up — smooth deceleration eases the
21+
// header back into place.
2122
ion-header.header-scroll-effect-hide {
2223
transition: transform 300ms cubic-bezier(0, 0, 0.2, 1),
2324
opacity 300ms cubic-bezier(0, 0, 0.2, 1);
2425
}
2526

26-
// Hidden state — hide is faster than show
27+
// Slide out of view when scrolling down — header moves off the top of the
28+
// screen and fades out. Faster than the return to feel responsive to scroll.
2729
ion-header.header-scroll-effect-hide.header-scroll-hidden {
2830
transform: translateY(-100%);
2931
opacity: 0;

0 commit comments

Comments
 (0)