summaryrefslogtreecommitdiffhomepage
path: root/ios/MullvadVPN/Classes/AutomaticKeyboardResponder.swift
blob: 00b83d3ed5a79e8dc87161c835caa4f0b4ec27f2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
//
//  AutomaticKeyboardResponder.swift
//  MullvadVPN
//
//  Created by pronebird on 24/03/2021.
//  Copyright © 2025 Mullvad VPN AB. All rights reserved.
//

import MullvadLogging
import UIKit

@MainActor
class AutomaticKeyboardResponder {
    weak var targetView: UIView?
    private let handler: (UIView, CGFloat) -> Void

    private var lastKeyboardRect: CGRect?

    init<T: UIView>(targetView: T, handler: @escaping (T, CGFloat) -> Void) {
        self.targetView = targetView
        self.handler = { view, adjustment in
            if let view = view as? T {
                handler(view, adjustment)
            }
        }

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(keyboardWillChangeFrame(_:)),
            name: UIResponder.keyboardWillChangeFrameNotification,
            object: nil
        )
    }

    func updateContentInsets() {
        guard let keyboardRect = lastKeyboardRect else { return }
        adjustContentInsets(convertedKeyboardFrameEnd: keyboardRect)
    }

    // MARK: - Keyboard notifications

    @objc private func keyboardWillChangeFrame(_ notification: Notification) {
        handleKeyboardNotification(notification)
    }

    // MARK: - Private

    private func handleKeyboardNotification(_ notification: Notification) {
        guard let userInfo = notification.userInfo,
            let targetView
        else { return }
        // In iOS 16.1 and later, the keyboard notification object is the screen the keyboard appears on.
        if #available(iOS 16.1, *) {
            guard let screen = notification.object as? UIScreen,
                // Get the keyboard’s frame at the end of its animation.
                let keyboardFrameEnd = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
            else { return }

            // Use that screen to get the coordinate space to convert from.
            let fromCoordinateSpace = screen.coordinateSpace

            // Get your view's coordinate space.
            let toCoordinateSpace: UICoordinateSpace = targetView

            // Convert the keyboard's frame from the screen's coordinate space to your view's coordinate space.
            let convertedKeyboardFrameEnd = fromCoordinateSpace.convert(keyboardFrameEnd, to: toCoordinateSpace)

            lastKeyboardRect = convertedKeyboardFrameEnd

            adjustContentInsets(convertedKeyboardFrameEnd: convertedKeyboardFrameEnd)
        } else {
            guard let keyboardValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue
            else { return }
            let keyboardFrameEnd = keyboardValue.cgRectValue
            let convertedKeyboardFrameEnd = targetView.convert(keyboardFrameEnd, from: targetView.window)
            lastKeyboardRect = convertedKeyboardFrameEnd

            adjustContentInsets(convertedKeyboardFrameEnd: convertedKeyboardFrameEnd)
        }
    }

    private func adjustContentInsets(convertedKeyboardFrameEnd: CGRect) {
        guard let targetView else { return }

        // Get the safe area insets when the keyboard is offscreen.
        var bottomOffset = targetView.safeAreaInsets.bottom

        // Get the intersection between the keyboard's frame and the view's bounds to work with the
        // part of the keyboard that overlaps your view.
        let viewIntersection = targetView.bounds.intersection(convertedKeyboardFrameEnd)

        // Check whether the keyboard intersects your view before adjusting your offset.
        if !viewIntersection.isEmpty {
            // Adjust the offset by the difference between the view's height and the height of the
            // intersection rectangle.
            bottomOffset = targetView.bounds.maxY - viewIntersection.minY
        }

        handler(targetView, bottomOffset)
    }
}

extension AutomaticKeyboardResponder {
    /// A convenience initializer that automatically assigns the offset to the scroll view
    /// subclasses
    convenience init(targetView: some UIScrollView) {
        self.init(targetView: targetView) { scrollView, offset in
            if scrollView.canBecomeFirstResponder {
                scrollView.contentInset.bottom = targetView.isFirstResponder ? offset : 0
                scrollView.verticalScrollIndicatorInsets.bottom =
                    targetView.isFirstResponder
                    ? offset
                    : 0
            } else {
                scrollView.contentInset.bottom = offset
                scrollView.verticalScrollIndicatorInsets.bottom = offset
            }
        }
    }
}