ionic-team / ionic-team/ionic-framework

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

オープン
#28,864 コメント 4 件 リアクション 0 件 担当者 0 名 GitHub で見る
package: core type: feature request
主要言語
TypeScript
スター
52.7k
フォーク
13.3k
平均マージ
1日 15時間
マージ済み PR(30日)
51

説明

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

コントリビューションガイド

コントリビューションガイドを開く

評価

この issue はまだ評価されていません。

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。