From 5fee04bc0832683bc2e8edf3d982e4aae8aaa4d8 Mon Sep 17 00:00:00 2001 From: jahorton Date: Wed, 15 Jan 2020 09:58:18 +0700 Subject: [PATCH] feat(ios): rule of thumb for scaling future devices --- .../Classes/KeyboardScaleMap.swift | 119 +++++++++++++++--- 1 file changed, 105 insertions(+), 14 deletions(-) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/KeyboardScaleMap.swift b/ios/engine/KMEI/KeymanEngine/Classes/KeyboardScaleMap.swift index 4bca29c261..a759ba4d2d 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/KeyboardScaleMap.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/KeyboardScaleMap.swift @@ -41,6 +41,16 @@ struct Scaling { class KeyboardScaleMap { private var scalings: [String: Scaling] + + /** + * Based off of Device's UIKit size (in points): refer to + * https://developer.apple.com/library/archive/documentation/DeviceInformation/Reference/iOSDeviceCompatibility/Displays/Displays.html + */ + private var pointSizes: [String: CGSize] + + private var phoneScalingThresholds: [Device] + private var tabletScalingThresholds: [Device] + static var _shared: KeyboardScaleMap? private init() { @@ -49,7 +59,7 @@ class KeyboardScaleMap { // All scalings below are taken from Simulator readings, using the most-up-to-date version of iOS that they support. // Note that the banner heights (at minimum) may vary across iOS versions! // - // None of the widths seem accurate, but I've yet to figure out how to get the exact readings. + // None of the widths seem accurate _for the keyboard_, but I've yet to figure out how to get the exact readings. // Nothing available for iOS 9.3.5 - Xcode does not provide a Simulator version. @@ -83,6 +93,8 @@ class KeyboardScaleMap { scalings[KeyboardScaleMap.hashKey(for: Device.iPhone8Plus)] = Scaling(portrait: KeyboardSize(w: 414, h: 226, b: 45), landscape: KeyboardSize(w: 736, h: 162, b: 38)) // does not use full width in landscape // Start: 'safe area insets', which are not counted (here) as part of a keyboard's height. + // Note that the widths reported for notched phones exclude part of the area to the sides in landscape b/c + // of safe-area guidelines. It's not exact, though, as the system globe & dictation keys go beyond those guides. scalings[KeyboardScaleMap.hashKey(for: Device.iPhoneX)] = Scaling(portrait: KeyboardSize(w: 375, h: 216, b: 45), landscape: KeyboardSize(w: 724, h: 150, b: 38)) // insets: p: 34, l: 21 scalings[KeyboardScaleMap.hashKey(for: Device.iPhoneXR)] = Scaling(portrait: KeyboardSize(w: 414, h: 226, b: 45), landscape: KeyboardSize(w: 808, h: 150, b: 38)) // insets: p: 34, l: 21 scalings[KeyboardScaleMap.hashKey(for: Device.iPhoneXS)] = Scaling(portrait: KeyboardSize(w: 414, h: 216, b: 44), landscape: KeyboardSize(w: 724, h: 150, b: 38)) // insets: p: 34, l: 21. And yeah, that '44' isn't a typo. @@ -97,6 +109,35 @@ class KeyboardScaleMap { scalings[KeyboardScaleMap.hashKey(for: Device.iPadPro9Inch)] = Scaling(portrait: KeyboardSize(w: 768, h: 265, b: 55), landscape: KeyboardSize(w: 1024, h: 353, b: 55)) // Banner always shows; merges copy ctrls with pred-banner. scalings[KeyboardScaleMap.hashKey(for: Device.iPadPro11Inch)] = Scaling(portrait: KeyboardSize(w: 834, h: 265, b: 55), landscape: KeyboardSize(w: 1194, h: 353, b: 55)) // Banner always shows; has a safe-area inset {p: 20, l: 20} scalings[KeyboardScaleMap.hashKey(for: Device.iPadPro12Inch3)] = Scaling(portrait: KeyboardSize(w: 1024, h: 328, b: 55), landscape: KeyboardSize(w: 1366, h: 423, b: 55)) // Banner always shows; has a safe-area inset {p: 20, l: 20} + + // The following definitions are in sorted order of increasing device resolution. + // Currrently, this also tends to correlates with the underlying devices' release dates. + phoneScalingThresholds = [ Device.iPhoneSE, // smallest phone for 13.0 + Device.iPhone8, // is 'pre-notch' + Device.iPhoneX, // smallest with 'notch' + Device.iPhoneXR, // largest current model + ] + + tabletScalingThresholds = [ Device.iPad5, // smallest supported tablet + Device.iPad7, // smallest 13.0 tablet + Device.iPadAir3, + Device.iPadPro11Inch, + Device.iPadPro12Inch3, // taller than others of width, but is most recent at the same scale. + ] + + pointSizes = [:] + + // `UIScreen.mains.bounds` values for threshold devices. Taken in "portrait" orientation. + pointSizes[KeyboardScaleMap.hashKey(for: Device.iPhoneSE)] = CGSize(width: 320, height: 568) + pointSizes[KeyboardScaleMap.hashKey(for: Device.iPhone8)] = CGSize(width: 375, height: 667) + pointSizes[KeyboardScaleMap.hashKey(for: Device.iPhoneX)] = CGSize(width: 375, height: 812) + pointSizes[KeyboardScaleMap.hashKey(for: Device.iPhoneXR)] = CGSize(width: 414, height: 896) + + pointSizes[KeyboardScaleMap.hashKey(for: Device.iPad5)] = CGSize(width: 768, height: 1024) + pointSizes[KeyboardScaleMap.hashKey(for: Device.iPad7)] = CGSize(width: 810, height: 1080) + pointSizes[KeyboardScaleMap.hashKey(for: Device.iPadAir3)] = CGSize(width: 834, height: 1112) + pointSizes[KeyboardScaleMap.hashKey(for: Device.iPadPro11Inch)] = CGSize(width: 834, height: 1194) + pointSizes[KeyboardScaleMap.hashKey(for: Device.iPadPro12Inch3)] = CGSize(width: 1024, height: 1366) } public static var shared: KeyboardScaleMap { @@ -114,6 +155,53 @@ class KeyboardScaleMap { return device.realDevice.description } + /** + * Compares the detected dimensions of the current device to those of devices with known keyboard dimensions, returning + * the largest device smaller than or equal to the detected dimensions. + */ + private static func getUnknownDeviceMapping() -> Device { + let _screenSize = UIScreen.main.bounds + + // Shouldn't happen, but just in case. + if _screenSize == CGRect.zero { + log.error("Cannot detect device dimensions; defaulting to smallest device for form factor.") + } + + // Convert to CGSize in portrait orientation. + var screenSize: CGSize + if _screenSize.width > _screenSize.height { + screenSize = CGSize(width: _screenSize.height, height: _screenSize.width) + } else { + screenSize = CGSize(width: _screenSize.width, height: _screenSize.height) + } + + // Array for search is determined by form-factor. + var searchSet: [Device] + if(UIDevice.current.userInterfaceIdiom == .phone) { + searchSet = KeyboardScaleMap.shared.phoneScalingThresholds + } else { + searchSet = KeyboardScaleMap.shared.tabletScalingThresholds + } + + // Okay, now that we've got the important values, it's time to search. + let potentialMatches = searchSet.compactMap { device in + let matchSize = KeyboardScaleMap.shared.pointSizes[KeyboardScaleMap.hashKey(for: device)]! + if matchSize.width > screenSize.width || // if searchSet's device is wider + matchSize.height > screenSize.height { // or taller + return nil as Device? // it's not a permitted match for mapping uses. + } else { + return device + } + } as [Device] + + if potentialMatches.count == 0 { + return searchSet[0] // in case no matches can be found (like the screenSize == CGRect.zero case) - use smallest + } else { + // Use the largest potential device, which is the last one returned from the mapping (b/c we presorted the base array) + return searchSet.last! + } + } + static func getDeviceDefaultKeyboardScale(forPortrait: Bool) -> KeyboardSize? { let device = Device.current @@ -121,24 +209,27 @@ class KeyboardScaleMap { return forPortrait ? scaling.portrait : scaling.landscape } + var isUnknown = false switch device { case .unknown(_): - // The expected case; applies to future models of iPhone and iPad. DeviceKit is of no help here. - - // VERY temporary stop-gap. A better rule-of-thumb is needed. - let dev = UIDevice.current - var scaling: Scaling - - if(dev.userInterfaceIdiom == .phone) { - scaling = shared.scalings[KeyboardScaleMap.hashKey(for: Device.iPhoneSE)]! - } else { - scaling = shared.scalings[KeyboardScaleMap.hashKey(for: Device.iPad5)]! + isUnknown = true + fallthrough + default: + if !isUnknown { + // We can still perform a mapping, but it's not ideal. + log.warning("Keyboard scaling definition missing for device \(device.description)") } + // The expected case: isUnknown = true. + // Applies to future models of iPhone and iPad. DeviceKit is of no help here, so we + // compute the closest-resolution known device. + let mappedDevice = getUnknownDeviceMapping() + + // As noted in the scalings data-map, devices of the same dimensions tend to have the same + // keyboard dimensions. It's not a perfect rule, but should suffice for a stop-gap solution. + let scaling = shared.scalings[KeyboardScaleMap.hashKey(for: mappedDevice)]! + return forPortrait ? scaling.portrait : scaling.landscape - default: - log.error("Keyboard scaling definition missing for device \(device.description)") - return nil } } }