Skip to content

Commit

Permalink
Hopefully a final solution for the soft keyboard in iOS.
Browse files Browse the repository at this point in the history
We manually set the gameport height in input blur/focus event handlers. Then when the visualViewport:resize event arrives later on, it should ideally be already the same height, or else very close to it.
I discovered that iOS actually sends 3 resize events on focus, for the keyboard being up, then down, then up again. So I throttle the resize handler in iOS.
Use the body-scroll-lock package to handle scrolling in iOS, letting me remove a bunch of old scrolling code. Note that the body and html elements need `height: 100%; width: 100%` CSS to be applied.
The TextInput.refocus() function was being called multiple times when the soft keyboard changed. Prevent it from being called more than once after each turn.
  • Loading branch information
curiousdannii committed Oct 27, 2024
1 parent dcb9d7f commit 08c2840
Show file tree
Hide file tree
Showing 6 changed files with 80 additions and 48 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
"type": "module",
"dependencies": {
"base32768": "^3.0.1",
"body-scroll-lock": "^4.0.0-beta.0",
"file-saver": "^2.0.5",
"lodash-es": "^4.17.21",
"mute-stream": "2.0.0",
"path-browserify-esm": "^1.0.6"
},
"devDependencies": {
"@types/body-scroll-lock": "^3.1.2",
"@types/file-saver": "^2.0.7",
"@types/jquery": "^3.5.31",
"@types/lodash-es": "^4.17.12",
Expand Down
58 changes: 28 additions & 30 deletions src/glkote/web/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ MIT licenced
https://github.com/curiousdannii/asyncglk
*/
import {throttle} from 'lodash-es'

import {debounce} from 'lodash-es'

import {KEY_CODE_DOWN, KEY_CODE_RETURN, KEY_CODE_UP, KEY_CODES_TO_NAMES, OFFSCREEN_OFFSET} from '../../common/constants.js'
import {is_pinch_zoomed} from '../../common/misc.js'
import * as protocol from '../../common/protocol.js'

import {is_input_focused, is_iOS} from './shared.js'
import {apply_text_run_styles, type Window} from './windows.js'

const MAX_HISTORY_LENGTH = 25
Expand All @@ -22,10 +24,12 @@ export class TextInput {
el: JQuery<HTMLElement>
history_index = 0
is_line = false
/** Whether this input has been refocused since it was last reset */
refocused = false
window: Window

constructor(window: Window) {
this.window = window
constructor(win: Window) {
this.window = win

// We use a textarea rather than an input because mobile Chrome shows an extra bar which can't be removed
// See https://github.com/curiousdannii/asyncglk/issues/30
Expand All @@ -34,7 +38,7 @@ export class TextInput {
autocapitalize: 'off',
class: 'Input',
data: {
window,
window: win,
},
on: {
blur: () => this.onblur(),
Expand All @@ -46,7 +50,7 @@ export class TextInput {
rows: 1,
})
.prop('disabled', true)
.appendTo(window.frameel)
.appendTo(win.frameel)
}

destroy() {
Expand All @@ -56,21 +60,20 @@ export class TextInput {
private onblur() {
// If this input lost focus and no other input gained focus, then tell the metrics to resize the gameport
// This is to support iOS better, which delays its `visualViewport:resize` event significantly (~700ms)
const input_is_active = document.activeElement?.tagName === 'INPUT'
if (!input_is_active) {
this.window.manager.glkote.metrics_calculator.set_gameport_height(window.innerHeight)
if (is_iOS && !is_input_focused()) {
this.set_gameport_height(true)
}

scroll_window()
}

private onfocus() {
// Ensure a buffer window is scrolled down
if (this.window.type === 'buffer' && !is_pinch_zoomed()) {
this.window.scroll_to_bottom()
}
// Scroll the browser window over the next 600ms
scroll_window()
// In iOS tell the metrics to resize the gameport because its `visualViewport:resize` event is slowww
if (is_iOS) {
this.set_gameport_height(false)
}
}

/** The keydown and keypress inputs are unreliable in mobile browsers with virtual keyboards. This handler can handle character input for printable characters, but not function/arrow keys */
Expand Down Expand Up @@ -175,12 +178,18 @@ export class TextInput {
/** Refocus the input, if it wouldn't obscure part of the update */
// On Android this forces the window to be scrolled down to the bottom, so only refocus if the virtual keyboard doesn't make the window too small for the full update text to be seen
refocus() {
if (this.refocused || document.activeElement === this.el[0]) {
return
}
this.refocused = true
if (this.window.type === 'buffer') {
const updateheight = this.window.innerel.outerHeight()! - this.window.updatescrolltop
if (updateheight > this.window.height_above_keyboard) {
// If there's not enough space, then tell the metrics to resize the gameport
// This is to support iOS better, which delays its `visualViewport:resize` event significantly (~700ms)
this.window.manager.glkote.metrics_calculator.set_gameport_height(window.innerHeight)
if (is_iOS) {
this.set_gameport_height(true)
}
return
}
}
Expand All @@ -189,6 +198,7 @@ export class TextInput {

reset() {
this.history_index = 0
this.refocused = false
this.el
.attr({
'aria-hidden': 'true',
Expand All @@ -208,6 +218,10 @@ export class TextInput {
}
}

private set_gameport_height = debounce((full_screen: boolean) => {
this.window.manager.glkote.metrics_calculator.set_gameport_height(full_screen ? window.innerHeight : 0)
}, 50)

private submit_char(val: string) {
this.window.send_text_event({
type: 'char',
Expand Down Expand Up @@ -284,20 +298,4 @@ export class TextInput {
}
}
}
}

/* A little helper function to repeatedly scroll the window, because iOS sometimes scrolls badly
On iOS, when focusing the soft keyboard, the keyboard animates in over 500ms
This would normally cover up the focused input, so iOS cleverly tries to
scroll the top-level window down to bring the input into the view
But we know better: we want to scroll the input's window frame to the bottom,
without scrolling the top-level window at all. */
const scroll_window = throttle(() => {
function do_scroll(count: number) {
window.scrollTo(0, 0)
if (count > 0) {
setTimeout(do_scroll, 50, count - 1)
}
}
do_scroll(12)
}, 1000)
}
25 changes: 21 additions & 4 deletions src/glkote/web/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {throttle} from 'lodash-es'
import {is_pinch_zoomed} from '../../common/misc.js'
import * as protocol from '../../common/protocol.js'

import {create} from './shared.js'
import {create, is_input_focused, is_iOS} from './shared.js'
import WebGlkOte from './web.js'

function get_size(el: JQuery<HTMLElement>): {height: number, width: number} {
Expand All @@ -35,10 +35,12 @@ function metrics_differ(newmetrics: protocol.NormalisedMetrics, oldmetrics: prot
}

export default class Metrics {
// Shares the current_metrics and DOM of WebGlkOte
private metrics: protocol.NormalisedMetrics
/** When we don't know how high the screen is, use a height we've saved before, or, at the very beginning, a rough estimate */
private height_with_keyboard = (visualViewport?.height || window.innerHeight) / 2
private loaded: Promise<void>
private glkote: WebGlkOte
// Shares the current_metrics and DOM of WebGlkOte
private metrics: protocol.NormalisedMetrics
private observer?: ResizeObserver

constructor(glkote: WebGlkOte) {
Expand All @@ -64,6 +66,11 @@ export default class Metrics {
else {
$(window).on('resize', this.on_gameport_resize)
}

// iOS sends repeated visualViewport:resize events, so throttle it
if (is_iOS) {
this.on_visualViewport_resize = throttle(this.on_visualViewport_resize, 700)
}
if (visualViewport) {
$(visualViewport).on('resize', this.on_visualViewport_resize)
}
Expand Down Expand Up @@ -179,9 +186,15 @@ export default class Metrics {
}, 200, {leading: false})

on_visualViewport_resize = () => {
// If the keyboard is active, then store the height for later
const height = visualViewport!.height
if (is_input_focused()) {
this.height_with_keyboard = height
}

// The iOS virtual keyboard does not change the gameport height, but it does change the viewport
// Try to account for this by setting the gameport to the viewport height
this.set_gameport_height(visualViewport!.height)
this.set_gameport_height(height)
}

/** Update the gameport height and then send new metrics */
Expand All @@ -191,6 +204,10 @@ export default class Metrics {
return
}

if (!height) {
height = this.height_with_keyboard
}

// We set the outer height to account for any padding or margin
this.glkote.dom.gameport().outerHeight(height, true)

Expand Down
12 changes: 11 additions & 1 deletion src/glkote/web/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,14 @@ export class DOM {
}
}

export type EventFunc = (event: Partial<protocol.Event>) => void
export type EventFunc = (event: Partial<protocol.Event>) => void

/** Is any input element focused? */
export function is_input_focused() {
const activeElement_tagName = document.activeElement?.tagName
return activeElement_tagName === 'INPUT' || activeElement_tagName === 'TEXTAREA'
}

/** Try to detect iOS */
// From https://stackoverflow.com/a/58065241/2854284
export const is_iOS = /iPad|iPhone|iPod/.test(navigator.platform) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
15 changes: 3 additions & 12 deletions src/glkote/web/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,11 @@ https://github.com/curiousdannii/asyncglk
*/

import {throttle} from 'lodash-es'

import * as GlkOte from '../common/glkote.js'
import * as protocol from '../../common/protocol.js'

import Metrics from './metrics.js'
import {DOM} from './shared.js'
import {DOM, is_iOS} from './shared.js'
import TranscriptRecorder from './transcript-recorder.js'
import Windows, {GraphicsWindow} from './windows.js'

Expand Down Expand Up @@ -101,8 +99,6 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt
}
windowport.empty()

$(document).on('scroll', this.on_document_scroll)

// Augment the viewport meta tag
// Rather than requiring all users to update their HTML we will add new properties here
// The properties we want are initial-scale, minimum-scale, width, and the new interactive-widget
Expand All @@ -112,7 +108,7 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt
// Prevent iOS from zooming in when focusing input, but allow Android to still pinch zoom
// As they handle the maximum-scale viewport meta option differently, we will conditionally add it only in iOS
// Idea from https://stackoverflow.com/a/62750441/2854284
if (/iPhone OS/i.test(navigator.userAgent)) {
if (is_iOS) {
viewport_meta_tag_content += ',maximum-scale=1'
}

Expand Down Expand Up @@ -208,6 +204,7 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt
'aria-label': 'Close',
click: () => {
errorpane.hide()
return false
},
id: 'errorclose',
text: '✖',
Expand Down Expand Up @@ -299,12 +296,6 @@ export default class WebGlkOte extends GlkOte.GlkOteBase implements GlkOte.GlkOt
}
}

// iOS devices can scroll the window even though body/#gameport are set to height 100%
// Scroll back to the top if they try
on_document_scroll = throttle(async () => {
window.scrollTo(0, 0)
}, 500, {leading: false})

save_allstate(): AutosaveState {
const graphics_bg: Array<[number, string]> = []
for (const win of this.windows.values()) {
Expand Down
16 changes: 15 additions & 1 deletion src/glkote/web/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ https://github.com/curiousdannii/asyncglk
*/

import {disableBodyScroll, enableBodyScroll} from 'body-scroll-lock'
import {debounce} from 'lodash-es'

import {Blorb} from '../../blorb/blorb.js'
Expand All @@ -17,7 +18,7 @@ import {is_pinch_zoomed} from '../../common/misc.js'
import * as protocol from '../../common/protocol.js'

import {TextInput} from './input.js'
import {create, DOM, type EventFunc} from './shared.js'
import {create, DOM, type EventFunc, is_iOS} from './shared.js'
import WebGlkOte from './web.js'

export type Window = BufferWindow | GraphicsWindow | GridWindow
Expand Down Expand Up @@ -338,12 +339,25 @@ export class BufferWindow extends TextualWindow {
tabindex: -1,
})
.on('scroll', this.onscroll)

if (is_iOS) {
disableBodyScroll(this.frameel[0])
}

this.innerel = create('div', 'BufferWindowInner')
.append(this.textinput.el)
.appendTo(this.frameel)
this.height_above_keyboard = this.frameel.height()!
}

destroy(remove_frame: boolean) {
if (is_iOS) {
enableBodyScroll(this.frameel[0])
}

super.destroy(remove_frame)
}

/** Measure the height of the window that is currently visible (excluding virtual keyboards for example) */
measure_height() {
this.height_above_keyboard = this.frameel.height()!
Expand Down

0 comments on commit 08c2840

Please sign in to comment.