[WKWebView] Focus해도 keyboard가 올라오지 않을 때

정유진·2022년 7월 28일
2

swift

목록 보기
7/25
post-thumbnail
post-custom-banner

🖤 keyboardDisplayRequiresUserAction

UIWebView에는 위 속성을 통해 유저의 interaction이 없더라도 프로그래밍으로 keyboard를 표시할 수 있었지만 해당 스펙이 WKWebView에서는 존재하지 않으므로..ㅎ (대체 왜 안 넣어준거야) 알음알음 검색하여 대안을 찾았다. 함수를 작성해주신 stackoverflow 형님들에게 감사를 전한다. 미래의 나를 위해 아카이빙

https://nshipster.com/wkwebview/
UIWebview의 스펙과 WKWebView의 스펙을 비교해볼 수 있는 페이지

💻 코드 보기

https://stackoverflow.com/questions/32449870/programmatically-focus-on-a-form-in-a-webview-wkwebview
감사합니다.. Objc 코드를 swift로 바꾸어 주셔서..

import UIKit
import Foundation
import WebKit

typealias OldClosureType =  @convention(c) (Any, Selector, UnsafeRawPointer, Bool, Bool, Any?) -> Void
typealias NewClosureType =  @convention(c) (Any, Selector, UnsafeRawPointer, Bool, Bool, Bool, Any?) -> Void

extension WKWebView {
func setKeyboardRequiresUserInteraction( _ value: Bool) {
        guard
            let WKContentViewClass: AnyClass = NSClassFromString("WKContentView") else {
                print("Cannot find the WKContentView class")
                return
        }

        let olderSelector: Selector = sel_getUid("_startAssistingNode:userIsInteracting:blurPreviousNode:userObject:")
        let newSelector: Selector = sel_getUid("_startAssistingNode:userIsInteracting:blurPreviousNode:changingActivityState:userObject:")
        let newerSelector: Selector = sel_getUid("_elementDidFocus:userIsInteracting:blurPreviousNode:changingActivityState:userObject:")
        let ios13Selector: Selector = sel_getUid("_elementDidFocus:userIsInteracting:blurPreviousNode:activityStateChanges:userObject:")

        if let method = class_getInstanceMethod(WKContentViewClass, olderSelector) {

            let originalImp: IMP = method_getImplementation(method)
            let original: OldClosureType = unsafeBitCast(originalImp, to: OldClosureType.self)
            let block : @convention(block) (Any, UnsafeRawPointer, Bool, Bool, Any?) -> Void = { (me, arg0, arg1, arg2, arg3) in
                original(me, olderSelector, arg0, !value, arg2, arg3)
            }
            let imp: IMP = imp_implementationWithBlock(block)
            method_setImplementation(method, imp)
        }

        if let method = class_getInstanceMethod(WKContentViewClass, newSelector) {
            self.swizzleAutofocusMethod(method, newSelector, value)
        }

        if let method = class_getInstanceMethod(WKContentViewClass, newerSelector) {
            self.swizzleAutofocusMethod(method, newerSelector, value)
        }

        if let method = class_getInstanceMethod(WKContentViewClass, ios13Selector) {
            self.swizzleAutofocusMethod(method, ios13Selector, value)
        }
    }

    func swizzleAutofocusMethod(_ method: Method, _ selector: Selector, _ value: Bool) {
        let originalImp: IMP = method_getImplementation(method)
        let original: NewClosureType = unsafeBitCast(originalImp, to: NewClosureType.self)
        let block : @convention(block) (Any, UnsafeRawPointer, Bool, Bool, Bool, Any?) -> Void = { (me, arg0, arg1, arg2, arg3, arg4) in
            original(me, selector, arg0, !value, arg2, arg3, arg4)
        }
        let imp: IMP = imp_implementationWithBlock(block)
        method_setImplementation(method, imp)
   }
}

🔬 뜯어보기

회사에서 웹뷰를 많이 쓰는데 나는 웹뷰에 대해서 너무 모르는 것 같다. 기회가 있을 때마다 웹뷰에 관련한 건 자세하게 뜯어보는 습관을 가지려 한다.

WKContentView

guard let WKContentViewClass: AnyClass = NSClassFromString("WKContentView") else {
    print("Cannot find the WKContentView class")
    return
}
  • NSClassFromString(:String) -> AnyClass 이름으로 클래스를 획득한다. (멋지다!)
  • WKContentView
    • WKApplicationStateTrackingView 를 implement하는 클래스
    • method들을 둘러보니 zoom이나 scrolling 등을 감지하고
    • property는 isFocusingElement, resigningFirstResponder 등을 가지고 있었다.
    • firstResponder를 어느 객체에 주느냐에 따라서 동작이 달라지는 것을 감안했을 때 (예를 들면 텍스트 필드를 firstResponder로 만들면 키보드가 올라오고 resign하면 키보드가 내려가게 된다.)
    • 해당 클래스를 통해서 keyboardDisplayRequiresUserAction = No와 같은 효과를 낼 수 있을 것이라 기대!

Selector

let olderSelector: Selector = sel_getUid("_startAssistingNode:userIsInteracting:blurPreviousNode:userObject:")
let newSelector: Selector = sel_getUid("_startAssistingNode:userIsInteracting:blurPreviousNode:changingActivityState:userObject:")
let newerSelector: Selector = sel_getUid("_elementDidFocus:userIsInteracting:blurPreviousNode:changingActivityState:userObject:")
let ios13Selector: Selector = sel_getUid("_elementDidFocus:userIsInteracting:blurPreviousNode:activityStateChanges:userObject:")

if let method = class_getInstanceMethod(WKContentViewClass, olderSelector) {
...
  • Selector는 swift가 objc 메서드에 접근하기 위해서 사용하는 타입
  • objc가 runtime language라서 swift와는 Method dispatch와는 다르다보니 objc 메서드를 dynamic하게 call하기 위해서는 selector를 사용할 수 밖에 없나보다. (참고: https://developer.apple.com/documentation/swift/using-objective-c-runtime-features-in-swift)
  • sel_getUid(:UnsafePointer<CChar\>) 내가 register 하고 싶은 메서드를 인자 값으로 넘기면 objc runtime system에 해당 메서드를 register할 수 있다. (아직 메서드를 가져온건 아님!)
  • class_getInstanceMethod(:AnyClass?,:Selector) -> Method 이름 그대로 해당 클래스에서 selector와 일치하는 이름의 메서드 객체를 반환한다.
  • 그렇다면 저 메서드의 이름은 어떻게 알아낸거야?

https://github.com/WebKit/WebKit
/Source/WebKit/UIProcess/ios/WKContentViewInteraction.h
선임님의 도움을 받았다. 아마 이러한 오픈소스에서 알아내는 것으로 보인다.
ios 14,15도 ios13과 동일한 메서드를 사용하는지 동작에는 문제가 없다.
코드 상으로는 userIsInteracting parameter에 bool 값을 넘겨서 제어한다.

- (void)_elementDidFocus:(const WebKit::FocusedElementInformation&)information userIsInteracting:(BOOL)userIsInteracting blurPreviousNode:(BOOL)blurPreviousNode activityStateChanges:(OptionSet<WebCore::ActivityState::Flag>)activityStateChanges userObject:(NSObject <NSSecureCoding> *)userObject;
  • const Webkit 하고 상수가 들어있기 때문에 Old/NewClosureType의 첫 번째 인자가 Any여도 상관이 없다.
  • 두 번째 인자는 뭔지는 몰라도 &을 보니 주소값과 관련이 있는듯 하다.

Implement

func swizzleAutofocusMethod(_ method: Method, _ selector: Selector, _ value: Bool) {
        let originalImp: IMP = method_getImplementation(method)
        let original: NewClosureType = unsafeBitCast(originalImp, to: NewClosureType.self)
        let block : @convention(block) (Any, UnsafeRawPointer, Bool, Bool, Bool, Any?) -> Void = { (me, arg0, arg1, arg2, arg3, arg4) in
            original(me, selector, arg0, !value, arg2, arg3, arg4)
        }
        let imp: IMP = imp_implementationWithBlock(block)
        method_setImplementation(method, imp)
   }
  • IMP는 Opaque C pointer를 의미한다 (환장하겠네)
  • method_getImplementation(:Method)->IMP method의 implementation을 반환한다. (objc에는 헤더랑 impl이 따로 있으니까..그래서일까?)
  • unsafeBitCast(_x:T, to:U.Type)->U
    • x: casting할 객체
    • to: casting할 type을 의미한다
    • casting은 특정 객체가 되도록 선물 포장하는 것이라고 이해하면 된다.
  • 이쯤에서 다시 살펴보는 아래의 NewClosureType으로 변환하여 반환해주는 것이다.
typealias NewClosureType =  @convention(c) (Any, Selector, UnsafeRawPointer, Bool, Bool, Bool, Any?) -> Void
  • @convention(c) c function에 swift의 closure를 넘길 수 있는 모양이다. 그러니 이 어노테이션이 붙어있다면 c function에 넘길 closure라고 보면 된다. (callback 함수가 될 것)
  • @convention(block) objc의 block과 swift의 closure는 근본적으로 다른데 이 어노테이션을 붙이면 closure를 objc runtime에 노출시킬 수 있는 것으로 보인다. (그냥 쉽게 swift closure를 objc block으로 바꿨다고 생각하면 된다.)
  • imp_implementationWithBlock(_ block:) -> IMP function에 대한 포인터를 생성한다.
    • block: 이 block은 method를 리턴하는 타입이어야 한다.
    • 반환 되는 IMP는 block을 실행하는 포인터이다. 사용후에는 반드시 dispose 해야 된다고 공식문서에는 적혀있다.
  • method_setImplementation(_m: Method, _imp: IMP) -> IMP 이제까지 바리바리 준비한 method의 implement를 최종적으로 set한다
  • method에게 imp를 주고 '야 impl하셈' 하면 method는 받은 param으로 수행을 할 것이다.

🙈 정리하며

그러게 애플은 왜 keyboardDisplayRequiresUserAction를 없애서 이렇게 복잡한 상황을 야기한 것이냐. swift에서 objc 메서드 하나 호출하는게 이렇게 복잡할 일인가. 쓰면서도 정말 머리가 아팠다. 결과적으로 focus를 했을 때에 이것이 UserAction이 아니지만 키보드를 나타나게 만들 수 있도록 setKeyboardRequiresUserInteraction(false) 를 하는 지난한 여정이었다.

profile
느려도 한 걸음 씩 끝까지
post-custom-banner

1개의 댓글

comment-user-thumbnail
2024년 7월 18일

감사합니다.
덕분에 사나흘 고민했던 문제가 해결되었습니다.
더하여 깊이있게 고찰하신 내용을 글로 남겨주셔서 제 배움에도 큰 도움이 되었습니다.

첨언드리면 OS 13 ~ 17까지 동작이 되네요.

답글 달기