ionic-team / ionic-team/ionic-framework

feat: Ability to locate and animate content relative to the keyboard

Aperta
#28,864 4 commenti 0 reazioni 0 assegnatari Vedi su GitHub
package: core type: feature request
Lingua principale
TypeScript
Stelle
52.7k
Fork
13.3k
Merge medio
1g 15h
PR unite (30g)
51

Descrizione

### Prerequisites

- [X] I have read the [Contributing Guidelines](https://github.com/ionic-team/ionic-framework/blob/main/.github/CONTRIBUTING.md#creating-an-issue).
- [X] I agree to follow the [Code of Conduct](https://ionicframework.com/code-of-conduct).
- [X] I have searched for [existing issues](https://github.com/ionic-team/ionic-framework/issues) that already include this feature request, without success.

### Describe the Feature Request

It would be great if ionic handled animating the ion-footer (or ion-toolbar) on mobile when the keyboard opens & closes.

The implementation could look something like this:
https://github.com/ionic-team/ionic-framework/assets/41165256/6103fc21-1988-488e-bcbc-4d4123621fd1

With additional animations when the keyboard height changes while open like this:
https://github.com/ionic-team/ionic-framework/assets/41165256/3540978e-86c7-4936-bcc2-6e8f44570029

Additionally, there could be options for also resizing the ion-content's inner scrollable container.

This could be a new keyboard resize mode potentially?

### Describe the Use Case

This would provide a vastly better UX when ion-footer's & keyboards together.

### Describe Preferred Solution

_No response_

### Describe Alternatives

I've built my own solution to this, but it's far from perfect and was way too time-consuming to be viable as a general solution to this.

Some issues still remain like, if I navigate to a different page, if a dont let the ion-footer animation complete before the page transitions, the ion-footer on the next page is in the wrong place... I'm sure there's a better under-the-hood solution to make the animation work better with the navigation animation.

### Related Code

here's an example of the service I've made for this animation:

```
@Injectable({
providedIn: 'root',
})
export class ChatPageFooterAnimationSvc {

/**
* tracks the height of the keyboard when it was last opened
*/
onUpHeight: number;
/**
* tracks the current height of the keyboard. useful for comparing
* with onUpHeight to see if the keyboard height has changed
* (e.g. if user switches from emoji keyboard to regular keyboard)
*/
currentKeyboardHeight: number;

footer: Animation;
content: Animation;

ionContentHeight = document.body.clientHeight - 64 - 64;

ionToolbarEl: Element;
ionContentScrollEl: Element;

private stop = new Subject();

constructor(
private deviceSvc: DeviceSvc,
private keyboardSvc: KeyboardSvc,
private animationSvc: AnimationController,
private chatPageContentScrollSvc: ChatPageContentScrollSvc,
) {
onUnload(this.destroy);

// listens for when the keyboard is opened & triggers onUp animation
this.keyboardSvc.willShow$.pipe(
takeUntil(this.stop),
// willShow$ is emitted both when keyboard first appears, and when an opened keyboard changes height.
// so to tell if this is an event that should be handled by onUp(), we can check if a previous onUp()
// call has already set a truthy value for onUpHeight. If it has, then we know that the keyboard is
// currently visible & so this willShow event should NOT be handled by onUp()
filter(() => !this.onUpHeight),
map(event => correctKeyboardHeight(event.keyboardHeight, this.deviceSvc.model)),
).subscribe(async keyboardHeight =>
this.onUp(keyboardHeight)
);

// listens for when an opened keyboard changes height & triggers onChange styling
this.keyboardSvc.willShow$.pipe(
takeUntil(this.stop),
// willShow$ is emitted both when keyboard first appears, and when an opened keyboard changes height.
// so to tell if this is an event that should be handled by onChange(), we can check if the onUp()
// function has already set a value for onUpHeight. If it has, then we know that the keyboard is
// currently visible & so this willShow event should be handled by onChange()
filter(()=> !!this.onUpHeight),
map(event => correctKeyboardHeight(event.keyboardHeight, this.deviceSvc.model)),
).subscribe(async keyboardHeight =>
this.onChange(keyboardHeight)
)

// listens for when the keyboard is closed & triggers onDown animation
this.keyboardSvc.willHide$.pipe(
takeUntil(this.stop)
).subscribe(async () =>
this.onDown()
);
}

/**
* initializes some pre-reqs for the animation to function:
* @note the Keyboard MUST be set to KeyboardResize 'None' mode, otherwise:
* 1. animation measurements will be off (e.g. translateY's document reference position will change
* if KeyboardResize resizes the webview ('Native' mode) or the document ('Body' mode)...).
* 2. animations will be less smooth, b/c all the other KeyboardResize modes resize many more elements
* in the DOM tree - whereas with 'None' mode, we're only resizing the 2 elements passed to this service.
*/
init = async (): Promise => {
if (isMobile()) this.keyboardSvc.setResizeMode(KeyboardResize.None);
}

/**
* takes in the to-be-animated elements on the chatPage
*/
animateElement = async (el: Element): Promise => {
if (isWeb()) return;

if (isIonContentScrollEl(el)) this.ionContentScrollEl = el;
else this.ionToolbarEl = el;
}

/**
* preps the animation for the ionToolbarEl
*/
private createFooterAnimation = async (keyboardHeight: number): Promise => {
this.footer?.destroy();
this.footer = this.animationSvc
.create()
.duration(CHAT_PAGE_KEYBOARD_FOOTER_ANIMATION_DURATION_MS)
.iterations(1)
.easing('cubic-bezier(.43,1.1,.69,.94)')
.addElement(this.ionToolbarEl)
.from('transform', `translateY(0)`)
.to('transform', `translateY(-${keyboardHeight}px)`);
}

/**
* preps the animation for the ionContentScrollEl
*/
private createContentAnimation = async (keyboardHeight: number): Promise => {
this.content?.destroy();
this.content = this.animationSvc
.create()
.duration(250)
.iterations(1)
.easing('cubic-bezier(.43,1.1,.69,.94)')
.addElement(this.ionContentScrollEl)
.beforeStyles({height: `${this.ionContentHeight}px`, position: 'fixed', top: `${CHAT_PAGE_HEADER_HEIGHT_PX}px`})
.afterStyles({height: `${this.ionContentHeight - keyboardHeight}px`, position: 'fixed', top: `${CHAT_PAGE_HEADER_HEIGHT_PX}px`})
.from('transform', `translateY(0)`);
}

private createAnimations = async (keyboardHeight: number): Promise => {
await this.createContentAnimation(keyboardHeight);
await this.createFooterAnimation(keyboardHeight);
};

private onUp = async (keyboardHeight: number): Promise => {
// create animations must occur here, b/c this is the earliest we can get the keyboardHeight
await this.createAnimations(keyboardHeight);

// play animations in forward direction
this.footer.direction('normal').play({sync:false});
await this.content.direction('normal').play({sync:false});

// must scroll to bottom after animation is complete, else the bottom-most replies will be hidden
this.chatPageContentScrollSvc.scrollToBottom();

// set heights for onChange() & onDown() to leverage
[this.onUpHeight, this.currentKeyboardHeight] = [keyboardHeight, keyboardHeight];
}

private onDown = async (): Promise => {
// play animations in reverse direction
await this.footer.direction('reverse').beforeClearStyles(['position', 'bottom']).afterClearStyles(['position', 'bottom'])
.play({sync:true});
await this.content.direction('reverse').beforeClearStyles(['height', 'position', 'top']).afterClearStyles(['height', 'position', 'top'])
.play({sync:true});

// remove any style added by onChange()
this.ionToolbarEl.removeAttribute('style');
this.ionContentScrollEl.removeAttribute('style');

// must scroll to bottom after animation is complete, else the bottom-most replies will be hidden
this.chatPageContentScrollSvc.scrollToBottom();

// reset heights
[this.onUpHeight, this.currentKeyboardHeight] = [0, 0];
}

/**
* handles minor changes in the keyboard height when it's already opened
* @example when user switches from emoji keyboard to regular keyboard, its height changes
* @example when user switches from english keyboard to spanish keyboard, its height changes
*/
private onChange = async (keyboardHeight: number): Promise => {
this.currentKeyboardHeight = keyboardHeight;

this.ionContentScrollEl.setAttribute('style', `height: ${this.ionContentHeight - this.currentKeyboardHeight}px;`);
this.ionToolbarEl.setAttribute('style', `bottom: ${this.currentKeyboardHeight - this.onUpHeight}px;`);
}

/**
* resets the service state to it's inital state, so when/if the chatPage is re-entered,
* the page elements will be resubmited so the animation can be created using the new elements
*/
reset = (): void => this.destroyAnimations();

/**
* sometimes if you leave the chatPage with the animations still running,
* they'll cause some jankiness when you re-enter the chatPage. By stopping
* them before you leave the page, we avoid such jank.
*/
stopAnimations = async (): Promise => {
if (await firstValueFrom(this.keyboardSvc.isHidden$)) return;

this.footer.stop(), this.content.stop();
await delay(50)
await this.keyboardSvc.hide();
}

private destroy = (): void => (this.stop.next(), this.stop.complete(), this.destroyAnimations())
private destroyAnimations = (): void => (this.footer?.destroy(), this.content?.destroy());
}
```

Guida per i contributori

Apri la guida per i contributori

Direzione di ricerca

Start by reviewing the ion-footer, ion-toolbar, and ion-content keyboard behavior, including the existing KeyboardResize modes and keyboard show, hide, and height-change events. Define how footer and content animations should behave during keyboard transitions and page navigation, then verify the chosen behavior on mobile with changing keyboard heights.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Valutazione

Stack tecnologico
typescript
Ambito
frontend, mobile-dev
Tipo di issue
Funzionalità
Difficoltà
5/5
Tempo stimato
Più di una settimana
Stato di attività
Ferma
Chiarezza
Da chiarire
Idoneità per principianti
25/100

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.