fix(header): prevent flickering during iOS page transitions (#30705)

Issue number: resolves #25326

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

The header flickers upon page transition when on iOS mode and using a
condensed header:

**Entering Page Two (P1 → P2):**
When navigating to Page Two, which has a collapsing header (intended to
be hidden until scroll), the header briefly flashes into view. This
happens because the header is initially rendered with full `opacity: 1`
before the component's logic can apply `opacity: 0` to hide it, causing
a visible flicker.

**Navigating Back (P2 → P1):**
When navigating back, Page One's header briefly bleeds through the top
of Page Two. Although Page Two is on top (`z−index: 100`), its
collapsing header is set to `opacity: 0`. This transparency allows Page
One header (`z−index: 99`) to become visible underneath, as the
transparent area cannot block the content below it.

The header flickers upon page transition when on iOS mode and using a
fade header:

**Entering Page Two (P1 → P2):**
When navigating to Page Two, which has a fade header (should not have a
background on load), the header background briefly flashes into view.
This happens because the header is initially rendered with full
`opacity: 1` before the component's logic can apply `opacity: 0` to hide
it, causing a visible flicker.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- Added a transition-specific class that is applied to the condensed
ion-header element to override its default transparency.

This guarantees the header to act as an opaque block during the page
transition, eliminating visual flickering caused by early `opacity: 0`
or the header underneath bleeding through.

- Added a transition-specific class that is applied to the fade
ion-header element to override its default opaque background.

This guarantees the header to act as a transparent block during the page
transition, eliminating visual flickering caused by default `opacity:
1`.


## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

Dev build: `8.7.6-dev.11759524961.1cff6814`
This commit is contained in:
Maria Hutt
2025-10-14 10:48:35 -07:00
committed by GitHub
parent f44585657c
commit 820fa28543
2 changed files with 122 additions and 16 deletions

View File

@ -39,6 +39,15 @@
--opacity-scale: inherit;
}
/**
* Override styles applied during the page transition to prevent
* header flickering.
*/
.header-collapse-fade.header-transitioning ion-toolbar {
--background: transparent;
--border-style: none;
}
// iOS Header - Collapse Condense
// --------------------------------------------------
.header-collapse-condense {
@ -65,8 +74,6 @@
* since it needs to blend in with the header above it.
*/
.header-collapse-condense ion-toolbar {
--background: var(--ion-background-color, #fff);
z-index: 0;
}
@ -93,6 +100,28 @@
transition: all 0.2s ease-in-out;
}
/**
* Large title toolbar should just use the content background
* since it needs to blend in with the header above it.
*/
.header-collapse-condense ion-toolbar,
/**
* Override styles applied during the page transition to prevent
* header flickering.
*/
.header-collapse-condense-inactive.header-transitioning:not(.header-collapse-condense) ion-toolbar {
--background: var(--ion-background-color, #fff);
}
/**
* Override styles applied during the page transition to prevent
* header flickering.
*/
.header-collapse-condense-inactive.header-transitioning:not(.header-collapse-condense) ion-toolbar {
--border-style: none;
--opacity-scale: 1;
}
.header-collapse-condense-inactive:not(.header-collapse-condense) ion-toolbar.in-toolbar ion-title,
.header-collapse-condense-inactive:not(.header-collapse-condense) ion-toolbar.in-toolbar ion-buttons.buttons-collapse {
opacity: 0;

View File

@ -18,34 +18,51 @@ const focusController = createFocusController();
// TODO(FW-2832): types
/**
* Executes the main page transition.
* It also manages the lifecycle of header visibility (if any)
* to prevent visual flickering in iOS. The flickering only
* occurs for a condensed header that is placed above the content.
*
* @param opts Options for the transition.
* @returns A promise that resolves when the transition is complete.
*/
export const transition = (opts: TransitionOptions): Promise<TransitionResult> => {
return new Promise((resolve, reject) => {
writeTask(() => {
beforeTransition(opts);
runTransition(opts).then(
(result) => {
if (result.animation) {
result.animation.destroy();
const transitioningInactiveHeader = getIosIonHeader(opts);
beforeTransition(opts, transitioningInactiveHeader);
runTransition(opts)
.then(
(result) => {
if (result.animation) {
result.animation.destroy();
}
afterTransition(opts);
resolve(result);
},
(error) => {
afterTransition(opts);
reject(error);
}
afterTransition(opts);
resolve(result);
},
(error) => {
afterTransition(opts);
reject(error);
}
);
)
.finally(() => {
// Ensure that the header is restored to its original state.
setHeaderTransitionClass(transitioningInactiveHeader, false);
});
});
});
};
const beforeTransition = (opts: TransitionOptions) => {
const beforeTransition = (opts: TransitionOptions, transitioningInactiveHeader: HTMLElement | null) => {
const enteringEl = opts.enteringEl;
const leavingEl = opts.leavingEl;
focusController.saveViewFocus(leavingEl);
setZIndex(enteringEl, leavingEl, opts.direction);
// Prevent flickering of the header by adding a class.
setHeaderTransitionClass(transitioningInactiveHeader, true);
if (opts.showGoBack) {
enteringEl.classList.add('can-go-back');
@ -278,6 +295,40 @@ const setZIndex = (
}
};
/**
* Add a class to ensure that the header (if any)
* does not flicker during the transition. By adding the
* transitioning class, we ensure that the header has
* the necessary styles to prevent the following flickers:
* 1. When entering a page with a condensed header, the
* header should never be visible. However,
* it briefly renders the background color while
* the transition is occurring.
* 2. When leaving a page with a condensed header, the
* header has an opacity of 0 and the pages
* have a z-index which causes the entering page to
* briefly show it's content underneath the leaving page.
* 3. When entering a page or leaving a page with a fade
* header, the header should not have a background color.
* However, it briefly shows the background color while
* the transition is occurring.
*
* @param header The header element to modify.
* @param isTransitioning Whether the transition is occurring.
*/
const setHeaderTransitionClass = (header: HTMLElement | null, isTransitioning: boolean) => {
if (!header) {
return;
}
const transitionClass = 'header-transitioning';
if (isTransitioning) {
header.classList.add(transitionClass);
} else {
header.classList.remove(transitionClass);
}
};
export const getIonPageElement = (element: HTMLElement) => {
if (element.classList.contains('ion-page')) {
return element;
@ -291,6 +342,32 @@ export const getIonPageElement = (element: HTMLElement) => {
return element;
};
/**
* Retrieves the ion-header element from a page based on the
* direction of the transition.
*
* @param opts Options for the transition.
* @returns The ion-header element or null if not found or not in 'ios' mode.
*/
const getIosIonHeader = (opts: TransitionOptions): HTMLElement | null => {
const enteringEl = opts.enteringEl;
const leavingEl = opts.leavingEl;
const direction = opts.direction;
const mode = opts.mode;
if (mode !== 'ios') {
return null;
}
const element = direction === 'back' ? leavingEl : enteringEl;
if (!element) {
return null;
}
return element.querySelector('ion-header');
};
export interface TransitionOptions extends NavOptions {
progressCallback?: (ani: Animation | undefined) => void;
baseEl: any;