Upgrade to Pro — share decks privately, control downloads, hide ads and more …

iOS のキーボードと文字入力のすべて

Yoshimasa Niwa
September 21, 2020

iOS のキーボードと文字入力のすべて

iOS の UIKit は一見、文字入力のような基本的なことはとても簡単に行えるように思えます。

しかし、一度でも文字入力を扱ったことのある方は、その難しさと期待しない動作に頭を悩ましたことが一度はあると思います。

例えば、iOS のキーボードが文字入力に被ってしまう、キーボードの位置がずれる、変更したはずの文字が反映されない、日本語が入力できない...
実のところ、一般的に文字入力はとても複雑なユーザーインタラクションであり、さらに iOS はキーボードにも様々な種類があるため、一見すると正しそうな実装であっても、ドキュメントに記載のない様々な挙動により期待しない結果に終わってしまうことが多々あります。

このセッションでは、ほぼほとんどの方が経験する iOS の文字入力に関する些細な問題からとても大きな難題について、世界中で多くのユーザーが使う iOS アプリ開発や、iOS のキーボードフレームワーク「KeyboardGuide」などの開発など、これまで長い間蓄積してきた経験に基づいて、実際におこった問題を実例を踏まえて、その原因や対策を検証していきたいと思います。

対象とする方:
- iOS アプリ開発の経験がある中・上級者
- `UITxtView` に昔年の思いがある方
- `UIKeyboardWillChangeFrameNotification` などに昔年の思いがある方

前提とする知識:
- UIKit
- Foundation
- Unicode

詳細:
https://fortee.jp/iosdc-japan-2020/proposal/32f815cc-8b16-4321-9cf4-c74f70287190

Yoshimasa Niwa

September 21, 2020
Tweet

More Decks by Yoshimasa Niwa

Other Decks in Technology

Transcript

  1. private func updateKeyboardState(with notification: Notification) { guard let isLocal =

    notification.userInfo?[UIApplication.keyboardIsLocalUserInfoKey] as? Bool, let frame = notification.userInfo?[UIApplication.keyboardFrameEndUserInfoKey] as? CGRect else { return } // `UIResponder.keyboardWillChangeFrameNotification` _MAY BE_ posted with `CGRect.zero` frame. // Ignore it, which is useless. if frame == CGRect.zero { return } let keyboardScreen: UIScreen let coordinateSpace: UICoordinateSpace let keyboardFrame: CGRect if #available(iOS 13.0, *) { keyboardScreen = UIScreen.main coordinateSpace = keyboardScreen.coordinateSpace keyboardFrame = frame } else if let keyWindow = UIApplication.shared.keyWindow { keyboardScreen = keyWindow.screen coordinateSpace = keyWindow // Prior to iOS 13.0, keyboard frame in keyboard notifications is positioned wrongly and its X origin is always `0.0`. // Assign real X origin instead. let realKeyboardOriginX = coordinateSpace.convert(CGPoint.zero, from: keyboardScreen.coordinateSpace).x keyboardFrame = CGRect(x: realKeyboardOriginX, y: frame.origin.y, width: frame.size.width, height: frame.size.height) } else { return } // While the main screen bound is being changed, notifications _MAY BE_ posted with wrong frame. // Ignore it, because it will be eventual consistent with the following notifications. if keyboardScreen.bounds.width != keyboardFrame.width { return } dockedKeyboardState = KeyboardState(isLocal: isLocal, frame: keyboardFrame, coordinateSpace: coordinateSpace) }
  2. func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String)

    -> Bool { let resultText = (textView.text! as NSString).replacingCharacters(in: range, with: text) if resultText.count <= 10 { return true } return false }
  3. /* rl, rlm are locked on entrance and exit */

    static int32_t __CFRunLoopRun(CFRunLoopRef rl, CFRunLoopModeRef rlm, CFTimeInterval seconds, Boolean stopAfterHandle, CFRunLoopModeRef previousMode) { int64_t startTSR = (int64_t)mach_absolute_time(); if (__CFRunLoopIsStopped(rl)) { __CFRunLoopUnsetStopped(rl); return kCFRunLoopRunStopped; } else if (rlm->_stopped) { rlm->_stopped = false; return kCFRunLoopRunStopped; } mach_port_name_t dispatchPort = MACH_PORT_NULL; Boolean libdispatchQSafe = pthread_main_np() && ((HANDLE_DISPATCH_ON_BASE_INVOCATION_ONLY && NULL == previousMode) || (!HANDLE_DISPATCH_ON_BASE_INVOCATION_ONLY && 0 == _CFGetTSD(__CFTSDKeyIsInGCDMainQ))); if (libdispatchQSafe && (CFRunLoopGetMain() == rl) && CFSetContainsValue(rl->_commonModes, rlm->_name)) dispatchPort = _dispatch_get_main_queue_port_4CF(); dispatch_source_t timeout_timer = NULL; struct __timeout_context *timeout_context = (struct __timeout_context *)malloc(sizeof(*timeout_context)); if (seconds <= 0.0) { // instant timeout seconds = 0.0; timeout_context->termTSR = 0LL; } else if (seconds <= TIMER_INTERVAL_LIMIT) { dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, DISPATCH_QUEUE_OVERCOMMIT); timeout_timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue); dispatch_retain(timeout_timer); timeout_context->ds = timeout_timer; timeout_context->rl = (CFRunLoopRef)CFRetain(rl); timeout_context->termTSR = startTSR + __CFTimeIntervalToTSR(seconds); dispatch_set_context(timeout_timer, timeout_context); // source gets ownership of context dispatch_source_set_event_handler_f(timeout_timer, __CFRunLoopTimeout); dispatch_source_set_cancel_handler_f(timeout_timer, __CFRunLoopTimeoutCancel); uint64_t nanos = (uint64_t)(seconds * 1000 * 1000 + 1) * 1000; dispatch_source_set_timer(timeout_timer, dispatch_time(DISPATCH_TIME_NOW, nanos), DISPATCH_TIME_FOREVER, 0); dispatch_resume(timeout_timer); } else { // infinite timeout seconds = 9999999999.0; timeout_context->termTSR = INT64_MAX; } Boolean didDispatchPortLastTime = true; int32_t retVal = 0; do { uint8_t msg_buffer[3 * 1024]; #if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED mach_msg_header_t *msg = NULL; #elif DEPLOYMENT_TARGET_WINDOWS HANDLE livePort = NULL; Boolean windowsMessageReceived = false; #endif __CFPortSet waitSet = rlm->_portSet; rl->_ignoreWakeUps = false; if (rlm->_observerMask & kCFRunLoopBeforeTimers) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeTimers); if (rlm->_observerMask & kCFRunLoopBeforeSources) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeSources); __CFRunLoopDoBlocks(rl, rlm); Boolean sourceHandledThisLoop = __CFRunLoopDoSources0(rl, rlm, stopAfterHandle); if (sourceHandledThisLoop) { __CFRunLoopDoBlocks(rl, rlm); } Boolean poll = sourceHandledThisLoop || (0LL == timeout_context->termTSR); if (MACH_PORT_NULL != dispatchPort && ! didDispatchPortLastTime) { #if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED msg = (mach_msg_header_t *)msg_buffer; if (__CFRunLoopServiceMachPort(dispatchPort, &msg, sizeof(msg_buffer), 0)) { goto handle_msg; } #elif DEPLOYMENT_TARGET_WINDOWS if (__CFRunLoopWaitForMultipleObjects(NULL, &dispatchPort, 0, 0, &livePort, NULL)) { goto handle_msg; } #endif } didDispatchPortLastTime = false; if (!poll && (rlm->_observerMask & kCFRunLoopBeforeWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeWaiting); __CFRunLoopSetSleeping(rl); // do not do any user callouts after this point (after notifying of sleeping) // Must push the local-to-this-activation ports in on every loop // iteration, as this mode could be run re- entrantly and we don't // want these ports to get serviced. __CFPortSetInsert(dispatchPort, waitSet); __CFRunLoopModeUnlock(rlm); __CFRunLoopUnlock(rl); #if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED if (kCFUseCollectableAllocator) { objc_clear_stack(0); memset(msg_buffer, 0, sizeof(msg_buffer)); } msg = (mach_msg_header_t *)msg_buffer; __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), poll ? 0 : TIMEOUT_INFINITY); #elif DEPLOYMENT_TARGET_WINDOWS // Here, use the app-supplied message queue mask. They will set this if they are interested in having this run loop receive windows messages. // Note: don't pass 0 for polling, or this thread will never yield the CPU. __CFRunLoopWaitForMultipleObjects(waitSet, NULL, poll ? 0 : TIMEOUT_INFINITY, rlm->_msgQMask, &livePort, &windowsMessageReceived); #endif __CFRunLoopLock(rl); __CFRunLoopModeLock(rlm); // Must remove the local-to-this-activation ports in on every loop // iteration, as this mode could be run re- entrantly and we don't // want these ports to get serviced. Also, we don't want them left // in there if this function returns. __CFPortSetRemove(dispatchPort, waitSet); rl->_ignoreWakeUps = true; // user callouts now OK again __CFRunLoopUnsetSleeping(rl); if (!poll && (rlm->_observerMask & kCFRunLoopAfterWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopAfterWaiting); handle_msg:; rl->_ignoreWakeUps = true; #if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED mach_port_t livePort = msg ? msg->msgh_local_port : MACH_PORT_NULL; #endif #if DEPLOYMENT_TARGET_WINDOWS if (windowsMessageReceived) { // These Win32 APIs cause a callout, so make sure we're unlocked first and relocked after __CFRunLoopModeUnlock(rlm); __CFRunLoopUnlock(rl); if (rlm->_msgPump) { rlm->_msgPump(); } else { MSG msg; if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE | PM_NOYIELD)) { TranslateMessage(&msg); DispatchMessage(&msg); } } __CFRunLoopLock(rl); __CFRunLoopModeLock(rlm); sourceHandledThisLoop = true; } else #endif if (MACH_PORT_NULL == livePort) { // handle nothing } else if (livePort == rl->_wakeUpPort) { // do nothing on Mac OS #if DEPLOYMENT_TARGET_WINDOWS // Always reset the wake up port, or risk spinning forever ResetEvent(rl->_wakeUpPort); #endif } else if (livePort == rlm->_timerPort) { #if DEPLOYMENT_TARGET_WINDOWS // We use a manual reset timer to ensure that we don't miss timers firing because the run loop did the wakeUpPort this time // The only way to reset a timer is to reset the timer using SetWaitableTimer. We don't want it to fire again though, so we set the timeout to a large negative value. The timer may be reset again inside the timer handling code. LARGE_INTEGER dueTime; dueTime.QuadPart = LONG_MIN; SetWaitableTimer(rlm->_timerPort, &dueTime, 0, NULL, NULL, FALSE); #endif __CFRunLoopDoTimers(rl, rlm, mach_absolute_time()); } else if (livePort == dispatchPort) { __CFRunLoopModeUnlock(rlm); __CFRunLoopUnlock(rl); _CFSetTSD(__CFTSDKeyIsInGCDMainQ, (void *)6, NULL); #if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED _dispatch_main_queue_callback_4CF(msg); #elif DEPLOYMENT_TARGET_WINDOWS _dispatch_main_queue_callback_4CF(); #endif _CFSetTSD(__CFTSDKeyIsInGCDMainQ, (void *)0, NULL); __CFRunLoopLock(rl); __CFRunLoopModeLock(rlm); sourceHandledThisLoop = true; didDispatchPortLastTime = true; } else { // Despite the name, this works for windows handles as well CFRunLoopSourceRef rls = __CFRunLoopModeFindSourceForMachPort(rl, rlm, livePort); if (rls) { #if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED mach_msg_header_t *reply = NULL; sourceHandledThisLoop = __CFRunLoopDoSource1(rl, rlm, rls, msg, msg->msgh_size, &reply) || sourceHandledThisLoop; if (NULL != reply) { (void)mach_msg(reply, MACH_SEND_MSG, reply->msgh_size, 0, MACH_PORT_NULL, 0, MACH_PORT_NULL); CFAllocatorDeallocate(kCFAllocatorSystemDefault, reply); } #elif DEPLOYMENT_TARGET_WINDOWS sourceHandledThisLoop = __CFRunLoopDoSource1(rl, rlm, rls) || sourceHandledThisLoop; #endif } } #if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED if (msg && msg != (mach_msg_header_t *)msg_buffer) free(msg); #endif __CFRunLoopDoBlocks(rl, rlm); if (sourceHandledThisLoop && stopAfterHandle) { retVal = kCFRunLoopRunHandledSource; } else if (timeout_context->termTSR < (int64_t)mach_absolute_time()) { retVal = kCFRunLoopRunTimedOut; } else if (__CFRunLoopIsStopped(rl)) { __CFRunLoopUnsetStopped(rl); retVal = kCFRunLoopRunStopped; } else if (rlm->_stopped) { rlm->_stopped = false; retVal = kCFRunLoopRunStopped; } else if (__CFRunLoopModeIsEmpty(rl, rlm, previousMode)) { retVal = kCFRunLoopRunFinished; } } while (0 == retVal); if (timeout_timer) { dispatch_source_cancel(timeout_timer); dispatch_release(timeout_timer); } else { free(timeout_context); } return retVal; }
  4. __CFRunLoopRun() { while (true) { Call kCFRunLoopBeforeTimers observer callbacks; Call

    kCFRunLoopBeforeSources observer callbacks; Perform blocks queued by CFRunLoopPerformBlock; Call the callback of each version 0 CFRunLoopSource that has been signaled; if (any version 0 source callbacks were called) { Perform blocks newly queued by CFRunLoopPerformBlock; } if (I didn’t drain the main queue on the last iteration AND the main queue has any blocks waiting) { while (main queue has blocks) { perform the next block on the main queue } } else { Call kCFRunLoopBeforeWaiting observer callbacks; Wait for a CFRunLoopSource to be signaled, or wait for a timer to fire, or wait for a block to be added to the main queue; Call kCFRunLoopAfterWaiting observer callbacks; if (the event was a timer) { Call CFRunLoopTimer callbacks for timers that should have fired by now } else if (event was a block arriving on the main queue) { while (main queue has blocks) { Perform the next block on the main queue } } else { Look up the version 1 CFRunLoopSource for the event; if (I found a version 1 source) { Call the source’s callback; } } } Perform blocks queued by CFRunLoopPerformBlock; } }
  5. ଐੑ͚ͩΛมߋ͢Δ NSTextStorageͷมߋ ϋΠϥΠτͨ݁͠ՌΛ setAttributes(_:range:) ͱɺ addAttribute(_:value:range) ͔ addAttributes(:range:) Ͱߋ৽͢Δ beginEditing(),

    endEditing() ͷதͰߦ͏ setAttributedString(_:) ͸ݺΜͰ͸͍͚ͳ͍ɻͨͱ͑ϓϨʔ ϯςΩετ͕ಉ͡Ͱ΋ editedMask ͕ editedCharacters ʹͳΔ ͷͰແݶϧʔϓ͢Δ
  6. func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorage.EditActions, range editedRange: NSRange,

    changeInLength delta: Int) { // Only attributes are edited guard editedMask.contains(.editedCharacters) else { return } // Capture plain string let string = textStorage.string DispatchQueue.global(qos: .utility).async { let overflowedCount = min(self.maximumLength - string.unicodeScalars.count, 0) let overflowedBeginIndex = string.unicodeScalars.index(string.unicodeScalars.endIndex, offsetBy: overflowedCount) let overflowedBeginOffset = overflowedBeginIndex.utf16Offset(in: string) let overflowedEndOffset = string.unicodeScalars.endIndex.utf16Offset(in: string) let overflowedRange = NSRange(location: overflowedBeginOffset, length: overflowedEndOffset - overflowedBeginOffset) DispatchQueue.main.async { // Plain string is changed already guard textStorage.string == string else { return } textStorage.beginEditing() // Reset foreground color textStorage.addAttribute(.foregroundColor, value: UIColor.darkText, range: NSRange(location: 0, length: textStorage.length)) // Add foreground color for overflowed characters textStorage.addAttribute(.foregroundColor, value: UIColor.systemRed, range: overflowedRange) textStorage.endEditing() } } }