quasarframework / quasar

Quasar Framework - Build high-performance VueJS user interfaces in record time
https://quasar.dev
MIT License
26.02k stars 3.54k forks source link

QFooter positioning is off with keyboard open #7649

Open ninerarete opened 4 years ago

ninerarete commented 4 years ago

Describe the bug QFooter positioning is off when the keyboard is open in iOS.

Codepen/jsFiddle/Codesandbox (required) https://github.com/quasarframework/quasar/blob/dev/docs/src/layouts/gallery/whatsapp.vue

To Reproduce Steps to reproduce the behavior:

  1. Open https://quasar.dev/layout/gallery/whatsapp in iOS device
  2. Tap on message box
  3. QFooter position is at the top of the screen

Expected behavior QFooter should stay on top of the keyboard when open.

Screenshots https://imgur.com/6VzA6MN

thexeos commented 4 years ago

This issue is "caused" by this function: https://github.com/quasarframework/quasar/blob/3ec6dc4ebe54f9978458a149274f285838c6ddfe/ui/src/mixins/prevent-scroll.js#L61-L84

Visual Viewport API is used by Quasar. It is important for mitigating this issue: https://github.com/quasarframework/quasar/issues/5351

In this particular case, two things happen: the size of the q-app element changes to be the same as the available screen space once the keyboard is open, and document.scrollingElement.scrollTop = scrollTop is called. Changing the scroll position (scrolling the page down) means that , which is position relative to the bottom of the q-app element, is pushed upwards (as if you had manually scrolled the page down).

The "fix", in that demo, is calling document.scrollingElement.scrollTop = 0 after the keyboard was open. That instantly puts everything as it should be on the screen. Of course, changes need to be made to either onAppleResize or elsewhere to detect if the keyboard is being open from q-dialog or an input element inside the q-footer, and then act accordingly.

It actually seems even more confusing now. I've tried tracing that, and other functions that change the scrollTop, and it seems like none of them are getting called. It may be that the scrollTop value is set by the browser here. I am continuing to investigate.

Okay, the flow is as follows:

Visual Viewport API is resized when input is focused, this is handled here:

https://github.com/quasarframework/quasar/blob/43fdc439063c7ba1ba56315941918e8336d2410f/ui/src/plugins/Screen.js#L140

In the callback, the reactive $q.screen.height is updated:

https://github.com/quasarframework/quasar/blob/43fdc439063c7ba1ba56315941918e8336d2410f/ui/src/plugins/Screen.js#L58

This height change is handled by the layout code to define the size of the content element:

https://github.com/quasarframework/quasar/blob/e1356f5bcf14902df9ce0d7957b3b83b85ed9bbb/ui/src/components/layout/QLayout.js#L85

In the mean time, since the input is at the bottom of the screen and would be occluded by the virtual keyboard, WebKit triggers a scroll event to position the field within the "main" viewport, unaware of the asynchronous changes in Vue.

End result: "main" viewport had been scrolled down and document.scrollingElement.scrollTop is equal to the height of the keyboard, "visual" viewport had been shrunk down to the available screen space and with it the layout.

Attempting to set document.scrollingElement.scrollTop = 0 has mixed results. Sometimes it works, but there is a visual glitch, as the layout is resized instantly and iOS does smooth scroll of the page, there is a white area visible above the content that shrinks upwards. Other times, the resize event takes longer time to propagate, which means that when the reactive height changes, the iOS scroll was already prevented and iOS no longer reports the resize (I could not understand the behavior yet, it may be a bug), and the result is a full height page (as if keyboard wasn't there) with occluded input.

thexeos commented 4 years ago

Quick update. I was able to fix this in our application. This is roughly how I did it. I haven't tested this approach with the sample layout.

A new mixin was added to keep the scroll position constant. However, the solution seems to work without this, but I can see how delayed events could break everything, and this should mitigate that. It is based on src/mixins/prevent-scroll.js

import { client } from 'quasar/src/plugins/Platform.js'

const lockingKeys = []

if (client.is.ios) {
  let vpPendingUpdate

  window.visualViewport.addEventListener('scroll', function (evt) {
    if (vpPendingUpdate === true || !lockingKeys.length) {
      return
    }

    vpPendingUpdate = true

    requestAnimationFrame(() => {
      vpPendingUpdate = false

      if (document.scrollingElement.scrollTop !== 0) {
        document.scrollingElement.scrollTop = 0
      }
    })
  }, false)
}

export default {
  methods: {
    __preventScroll (lockingKey, state) {
      if (state&& lockingKeys.indexOf(lockingKey) === -1) {
        lockingKeys.push(lockingKey)
      } else if (!state) {
        const index = lockingKeys.indexOf(lockingKey)
        if (index > -1) {
          lockingKeys.splice(index, 1)
        }
      }
    }
  }
}

Then in the layout which has the footer, the __preventScroll is enabled in created and disabled in beforeDestroy.

In the component that handles the QInput, I've added the following:

<template>
  <q-input ref="inputWrapper" @focus="onFocus" />
</template>

<script>

import { client } from 'quasar/src/plugins/Platform.js'

export default {
  computed: {
    viewportHeight () {
      return this.$q.screen.height
    }
  },
  data () {
    return {
      requiresRefocus: client.is.ios || client.is.android,
      waitingToRefocus: false
    }
  },
  watch: {
    viewportHeight () {
      if (this.waitingToRefocus) {
        this.$refs.inputWrapper.$refs.input.classList.remove('detached')
        this.waitingToRefocus = false
      }

      // A scroll-to-bottom function needs to be called here, depending if the message list is scrolled to the bottom already
    },
  },
  methods: {
    onFocus(e) {
      if (this.waitingToRefocus) {
        this.$refs.inputWrapper.$refs.input.classList.remove('detached')
        this.waitingToRefocus = false
      }
    },
  },
  mounted () {
    const textarea = this.$refs.inputWrapper.$refs.input
    textarea.addEventListener('click', () => {
      if (textarea !== window.activeElement) {
        textarea.classList.add('detached')
        this.waitingToRefocus = true
      }
    }, false)
  }
}
</script>
<style>
.detached {
  position: fixed;
  top: 0;
  left: 0;
  opacity: 0;
  z-index: -1;
}
</style>

I've also updated src/plugins/Screen.js at https://github.com/quasarframework/quasar/blob/43fdc439063c7ba1ba56315941918e8336d2410f/ui/src/plugins/Screen.js#L53-L55

to be

const
  w = window.visualViewport !== void 0 ? window.visualViewport.width : window.innerWidth,
  h = window.visualViewport !== void 0 ? window.visualViewport.height : window.innerHeight

I was able to catch states when value of $q.screen.height was equal to old value (prior to resize event triggering), and it caused the content to occupy 100% of "window.height" instead of 100% of "window.visualViewport.height". The places where this value was (mis)used and caused problems:

https://github.com/quasarframework/quasar/blob/e1356f5bcf14902df9ce0d7957b3b83b85ed9bbb/ui/src/components/layout/QLayout.js#L85

https://github.com/quasarframework/quasar/blob/e639b83cbea1204cdc1b16f22e4f466eae71688a/ui/src/components/page/QPage.js#L35

Together, these changes made the virtual keyboard opening behavior consistent and smooth (previously, Safari would scroll down the viewport after focusing the input that would otherwise be occluded by the virtual keyboard).