While SwiftUI is being adopted rapidly by more and more companies/teams let’s not forget that for decades we lived in not just Swift but Objective-C environment with Storyboards and XIBs. You might still come across support or legacy refactoring roles positions with Storyboards experience. That’s how I encountered the following challenge.
Task
Implement a growing UITextView in sliding panel that has a dark dimmed overlay over presenting VC. Panel grows with content. Can be closed by dragging down. UI below:
Tools
Let’s split the task into separate smaller ones:
Create KeyboardInputViewController with growing UITextView with paddings and keyboard handling
Present this VC from parent controller
Let’s start with the first one: growing UITextView. There are multiple implementations across the web but for me GrowingTextView and RSKGrowingTextView are stable and flexible with enough customizations of the field itself and behaviour. You can choose any of them but we will continue with RSKGrowingTextView.
The second one can be solved by UISheetPresentationController. It’s a relatively new class which is made by Apple after years when devs were making their own implementation of sliding panels. By default we have 2 detents: .medium
and .large
. We can add a custom one and animate it’s change:
if let sheet = parentVC.sheetPresentationController {
sheet.animateChanges {
sheet.selectedDetentIdentifier = .medium
}
}
This looks like a solid out-of-the-box solution. We even have a grabber to drag the panel. However we have no scroll offset of the panel and tap on dimmed area to dismiss. Of course, with a couple of GestureRecognizers it can be solved and there is even a snippet for this. Still, the grabber isn’t customizable, so for our specific needs we will use a well-known FloatingPanel. Popularity and uniqueness of this UI control is remarkable. I’m using it myself for years and it’s working like a charm. Take a closer look if you haven’t seen it before.
Panel content VC
All implementation here is straightforward. We are adding RSKGrowingTextView with anchor to top and bottom. Then bottom constraint is adjusted based on keyboard frame. For AutoLayout you can use any of DSL or activate it natively. For current case will use SnapKit.
import UIKit
import SnapKit
import RSKGrowingTextView
final class KeyboardInputViewController: UIViewController {
var heightDidChange: ((_ fieldHeight: CGFloat, _ totalHeight: CGFloat) -> Void)?
private lazy var inputTextView: RSKGrowingTextView = {
let view = RSKGrowingTextView()
view.delegate = self
view.textColor = .black
view.backgroundColor = .clear
view.font = .systemFont(ofSize: 19)
view.maximumNumberOfLines = 10
return view
}()
private var bottomConstraint: Constraint?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = DesignSystem.Color.floatingBackground
setupInputTextView()
registerForKeyboardNotifications()
//Setting cursor color
UITextField.appearance().tintColor = .red
UITextView.appearance().tintColor = .red
}
private let topInset = 16.0
private func setupInputTextView() {
view.addSubview(inputTextView)
inputTextView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(topInset)
self.bottomConstraint = make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom).offset(topInset / 2.0).constraint
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
//Small delay to show the keyboard
Task {
try? await Task.sleep(for: .milliseconds(300))
inputTextView.placeholder = "To Search, start typing"
inputTextView.placeholderColor = DesignSystem.Color.white20
inputTextView.becomeFirstResponder()
}
}
/// Registering keyboard notifs
private func registerForKeyboardNotifications() {
NotificationCenter.default.addObserver(self,
selector: #selector(handleKeyboardWillChangeFrame),
name: UIResponder.keyboardWillChangeFrameNotification,
object: nil)
}
//Property to store keyboard height
private var keyboardHeight: CGFloat = 0.0
/// Keyboard notification handler
/// - Parameter notification: notification
@objc private func handleKeyboardWillChangeFrame(notification: Notification) {
guard let userInfo = notification.userInfo,
let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
let animationCurveRawValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt else {
return
}
if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
let keyboardHeight = keyboardSize.height
self.bottomConstraint?.update(offset: -keyboardHeight + topInset)
let totalHeight = inputTextView.frame.height + view.safeAreaInsets.bottom + topInset + keyboardHeight
heightDidChange?(inputTextView.frame.height, totalHeight)
self.keyboardHeight = keyboardHeight
//Animate UI
let animationOptions = UIView.AnimationOptions(rawValue: animationCurveRawValue << 16)
UIView.animate(withDuration: animationDuration, delay: 0, options: animationOptions, animations: {
self.view.layoutIfNeeded()
}, completion: nil)
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
//Calculating new content height
let totalHeight = inputTextView.frame.height + view.safeAreaInsets.bottom + topInset + keyboardHeight
heightDidChange?(inputTextView.frame.height, totalHeight)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
extension InputViewController: RSKGrowingTextViewDelegate {
func growingTextView(_ textView: RSKGrowingTextView, willChangeHeight height: CGFloat) {
//Animate frame of TextView
UIView.animate(withDuration: 0.2) {
self.view.layoutIfNeeded()
}
}
}
Panel Layout
Now we need a PanelLayout to inject the sizes of our InputViewController. More info can be found here. In general - customizing the layout with FloatingPanelLayout
protocol by setting achors
and passing height
to it.
import FloatingPanel
import UIKit
final class FloatingPanelIntrinsicLayout: FloatingPanelLayout {
//Our panel content height property
var contentHeight: CGFloat
init(initialHeight: CGFloat) {
self.contentHeight = initialHeight
}
var position: FloatingPanelPosition { .bottom }
var initialState: FloatingPanelState { .half }
//Anchoring to the bottom of superview
var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] {
return [
.half: FloatingPanelLayoutAnchor(absoluteInset: contentHeight, edge: .bottom, referenceGuide: .superview)
]
}
//Alpha of the backdrop
func backdropAlpha(for state: FloatingPanelState) -> CGFloat {
switch state {
case .full, .half: return 0.5
default: return 0.0
}
}
}
Presenter VC logic
Now we need to present it from parent UIViewController. We just need one property to hold FloatingPanelController
and a method to initialise it.
import UIKit
import SnapKit
import FloatingPanel
class RemoteViewController: UIViewController {
//Field height for dismiss handling
private var searchFieldHeight: CGFloat = 0.0
private func presentChatPanel() {
let fpc = FloatingPanelController()
let inputVC = KeyboardInputViewController()
fpc.set(contentViewController: inputVC)
//Panel layout setup
fpc.surfaceView.appearance.cornerRadius = 12
fpc.surfaceView.grabberHandle.isHidden = false
fpc.surfaceView.grabberHandleSize = .init(width: 32, height: 4)
fpc.surfaceView.grabberHandle.backgroundColor = .gray
fpc.isRemovalInteractionEnabled = true
fpc.surfaceView.backgroundColor = .lightGray
fpc.backdropView.dismissalTapGestureRecognizer.isEnabled = true
fpc.delegate = self
// Initially set a height
fpc.layout = FloatingPanelIntrinsicLayout(initialHeight: 0.0)
//Tracking height update
inputVC.heightDidChange = { [weak fpc, weak self] fieldHeight, newHeight in
Task { @MainActor in
self?.searchFieldHeight = fieldHeight
guard let fpc = fpc else { return }
(fpc.layout as? FloatingPanelIntrinsicLayout)?.contentHeight = newHeight
fpc.invalidateLayout()
}
}
present(fpc, animated: true, completion: nil)
fpc.backdropView.backgroundColor = .black
fpc.backdropView.alpha = 0.8
floatingPanel = fpc
}
}
Close-on-Drag
As a final touch, we’ll implement auto-dismiss on draw of visible TextView height. For that we will handle FloatingPanelControllerDelegate
.
extension ParentViewController: @preconcurrency FloatingPanelControllerDelegate {
func floatingPanelDidMove(_ fpc: FloatingPanelController) {
guard fpc.contentViewController is InputViewController else { return }
//Getting surface location offset
let minY = fpc.surfaceLocation(for: .half).y + searchFieldHeight
if fpc.surfaceLocation.y > minY {
fpc.dismiss(animated: true)
}
}
func floatingPanelDidChangeState(_ fpc: FloatingPanelController) {
if fpc.state == .tip {
fpc.dismiss(animated: true)
}
}
}
That’s it — happy coding!