ionic-team / ionic-team/ionic-framework

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

Abierto
#28,864 4 comentarios 0 reacciones 0 asignados Ver en GitHub
package: core type: feature request
Lenguaje dominante
TypeScript
Estrellas
52.7k
Forks
13.3k
Merge medio
1 d 15 h
PR fusionados (30 d)
51

Descripción

### 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());
}
```

Guía de contribución

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.