ionic-team / ionic-team/ionic-framework
feat: Ability to locate and animate content relative to the keyboard
- 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