Last active
November 19, 2024 16:33
-
-
Save hannesoid/74ec9022021835598acf17564ce76a5a to your computer and use it in GitHub Desktop.
Use a SwiftUI View as inputAccessoryView, dynamically adjusting to height changes
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// (c) Hannes Oud @hannesoid | |
/// Hosts a SwiftUI view for use as an`inputAccessoryView` | |
/// | |
/// - Implements a subclass of `UIInputViewController`, this allows setting it as `UITextView.inputAccessoryViewController` | |
/// - Has as a `.view` a `UIView` subclass that provides a `.intrinsicContentSize` which returns the height of a SwiftUI view | |
/// - Has a `UIHostingViewController` subclass as a child view controller. Invalidates the `.view`'s `intrinsicContentSize` when the SwiftUI view layouts, in order to inform the system to update the size of the `inputAccessoryView` | |
/// | |
/// **Usage** | |
/// | |
/// ``` | |
/// /// Subclass `UITextView` in order to be able to set `inputAccessoryViewController` | |
/// final class TextView: UITextView { | |
/// private var _inputAccessoryViewController: UIInputViewController? | |
/// | |
/// // Overrides the readonly `UITextView.inputAccessoryViewController` property in order to be able to set it (as described in docs) | |
/// override var inputAccessoryViewController: UIInputViewController? { | |
/// get { _inputAccessoryViewController } | |
/// set { _inputAccessoryViewController = newValue } | |
/// } | |
/// } | |
/// | |
/// // Configure your textview with a InputAccessoryHostingController | |
/// let myTextView = TextView() | |
/// myTextView.inputAccessoryViewController = InputAccessoryHostingController { | |
/// Text("Yolo") // SwiftUI ViewBuilder | |
/// } | |
///``` | |
class InputAccessoryHostingController<InputAccessoryViewType: View>: UIInputViewController { | |
typealias ViewMaker = () -> InputAccessoryViewType | |
/// Serves as self.view, has a custom `intrinsicContentSize` implementation which fetches the actual fitting size of the swift UI view | |
private lazy var dynamicIntrinsicHeightView: DynamicIntrinsicHeightView = .init(frame: .init(origin: .zero, size: self.initialDimensions)) | |
private lazy var hostingController = InputAccessoryInnerHostingController( | |
rootView: self.makeAccessoryView(), | |
didUpdateSize: { | |
self.dynamicIntrinsicHeightView.invalidateIntrinsicContentSize() | |
}) | |
private let initialDimensions: CGSize = .init(width: 300, height: 100) | |
private var makeAccessoryView: ViewMaker | |
init(@ViewBuilder makeAccessoryView: @escaping ViewMaker) { | |
self.makeAccessoryView = makeAccessoryView | |
super.init(nibName: nil, bundle: nil) | |
} | |
// MARK: - UIView | |
required init?(coder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
override func loadView() { | |
self.view = self.dynamicIntrinsicHeightView | |
} | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
// Add hostingController as child and its SwiftUI view as subview | |
self.addChild(self.hostingController) | |
self.view.addSubview(self.hostingController.view) | |
self.hostingController.didMove(toParent: self) | |
self.hostingController.view.frame = self.view.frame | |
// self.hostingController.view.backgroundColor = UIColor.yellow // enable to see areas | |
self.hostingController.view.autoresizingMask = [.flexibleHeight, .flexibleWidth] | |
self.dynamicIntrinsicHeightView.fetchFittedHeight = { [unowned self] in | |
self.hostingController.hostedViewRecommendedSize(forWidth: self.view.frame.width).height | |
} | |
self.view.translatesAutoresizingMaskIntoConstraints = false | |
// self.view.backgroundColor = .blue | |
UIView.performWithoutAnimation { | |
self.view.layoutSubviews() | |
} | |
} | |
// MARK: - DynamicIntrinsicHeightView | |
/// Has a dynamic`intrinsicContentSize` height which is determined by an injected closure | |
private final class DynamicIntrinsicHeightView: UIView { | |
var fetchFittedHeight: () -> CGFloat = { 0 } | |
override init(frame: CGRect) { | |
super.init(frame: frame) | |
self.fetchFittedHeight = { [unowned self] in self.frame.height } | |
} | |
required init?(coder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
override var intrinsicContentSize: CGSize { | |
// note: a difference between height here and initial frame height causes a jerky appearance animation | |
return .init(width: UIView.noIntrinsicMetric, height: self.fetchFittedHeight()) | |
} | |
} | |
// MARK: - InputAccessoryInnerHostingController | |
final class InputAccessoryInnerHostingController: UIHostingController<InputAccessoryViewType> { | |
var didUpdateSize: () -> Void | |
init(rootView: InputAccessoryViewType, didUpdateSize: @escaping () -> Void) { | |
self.didUpdateSize = didUpdateSize | |
super.init(rootView: rootView) | |
} | |
required dynamic init?(coder aDecoder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
override func viewDidLayoutSubviews() { | |
super.viewDidLayoutSubviews() | |
self.didUpdateSize() | |
} | |
func hostedViewRecommendedSize(forWidth width: CGFloat) -> CGSize { | |
let fittingSize = self.view.systemLayoutSizeFitting(.init(width: self.view.frame.width, height: .greatestFiniteMagnitude), withHorizontalFittingPriority: .defaultHigh, verticalFittingPriority: .defaultHigh) | |
return .init(width: width, height: fittingSize.height) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment