Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
3 tasks done
drakedeatonuk opened this issue Jan 22, 2024 · 3 comments
Open
3 tasks done
Labels
package: core @ionic/core package type: feature request a new feature, enhancement, or improvement

Comments

@drakedeatonuk
Copy link

drakedeatonuk commented Jan 22, 2024

Prerequisites

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<void>();

  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<void> => {
    if (isMobile()) this.keyboardSvc.setResizeMode(KeyboardResize.None);
  }

  /**
   * takes in the to-be-animated elements on the chatPage
   */
  animateElement = async (el: Element): Promise<void> => {
    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<void> => {
    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<void> => {
    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<void> => {
    await this.createContentAnimation(keyboardHeight);
    await this.createFooterAnimation(keyboardHeight);
  };

  private onUp = async (keyboardHeight: number): Promise<void> => {
    // 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<void> => {
    // 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<void> => {
    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<void> => {
    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());
}
@ionitron-bot ionitron-bot bot added the triage label Jan 22, 2024
@sean-perkins
Copy link
Contributor

Hello @drakedeatonuk thanks for this feature request!

I can see you have also experienced the pain that is trying to animate any content relative to the keyboard position! Based on the current state of technology, this is difficult for anyone to do perfectly in all environments. Devices are notoriously inconsistent with the rendered keyboard height.

We have to use a mixed strategy of starting an animation when the keyboard show event is dispatched and doing a best guess to the animation duration & curve when animating to a fixed position relative to the keyboard height. Almost every implementation I've seen that has tried this either animates their custom content a little off from the keyboard or they wait until the keyboard has fully appeared before revealing an accessory bar.

I will discuss with the team and see if we have opinions on how to best handle this. Due to the limitation of how well we can also do this out of the box, potentially documentation and/or a blog may be better suited so developers can customize the implementation to match their device demographics best.

@sean-perkins sean-perkins changed the title feat: animate ion-footer on keyobard open & close feat: animate ion-footer on keyboard open & close Jan 29, 2024
@drakedeatonuk
Copy link
Author

@sean-perkins I feel like there should be a support group from people who've gone down the custom mobile keyboard content animate rabbit hole!

A blog or a page of documentation on best practices/ different approaches would be very valuable I think. I know long I spent looking for resources and not quite ever finding the help I wanted.

Perhaps some examples of approaches to try could be included too. Recently I've been toying with animating a transform: translateY on a keyboardWillShow event with the keyboardResize mode set to Native - that's been quite a smooth looking transition although there's still some edge cases I need to work out.

There are truly some serious "gotchyas" when it comes to keyboard events too. For example, when a keyboard with auto-fill suggestions at it's top opens, ios actually sends two onWillShow and onDidShow events. This in particular created some headaches for creating animations as it makes it much harder to figure out if keyboard events were, from the users perspective, related to a keyboard appearing, or changing height.

Recently I've been gathering some measurements from ever ios devices as it seems that keyboard heights are always consistently off by a certain amount:

Screenshot 2024-01-30 at 21 32 36

Can I ask, have you guys ever had any luck with this type of approach?

@sean-perkins sean-perkins self-assigned this Mar 7, 2024
@sean-perkins
Copy link
Contributor

Hello @drakedeatonuk apologies for the delay.

Static offsets might be difficult to manage over time and respond to dynamic conditions (screen rotation, etc.).

I would like to track this feature as introducing a new component/API that enables developers to slot through custom content that can respond with reasonable accuracy to the presence of the keyboard and adapt to the height changes with features like the accessory bar.

@sean-perkins sean-perkins changed the title feat: animate ion-footer on keyboard open & close feat: Ability to locate and animate content relative to the keyboard Mar 7, 2024
@sean-perkins sean-perkins added the type: feature request a new feature, enhancement, or improvement label Mar 7, 2024
@ionitron-bot ionitron-bot bot removed the triage label Mar 7, 2024
@sean-perkins sean-perkins removed their assignment Mar 7, 2024
@sean-perkins sean-perkins added the package: core @ionic/core package label Mar 7, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
package: core @ionic/core package type: feature request a new feature, enhancement, or improvement
Projects
None yet
Development

No branches or pull requests

2 participants