Skip to content

Instantly share code, notes, and snippets.

@hannesoid
Last active January 18, 2024 12:13
Show Gist options
  • Save hannesoid/74ec9022021835598acf17564ce76a5a to your computer and use it in GitHub Desktop.
Save hannesoid/74ec9022021835598acf17564ce76a5a to your computer and use it in GitHub Desktop.
Use a SwiftUI View as inputAccessoryView, dynamically adjusting to height changes
// (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