I am writing from scratch growing UITextView in my swift app.
I put a textView on the view like this:
it is right above the keyboard.
The textView has constraints attached to the view: leading, bottom, top and trailing, all equals = 4.
The view has the following constraints:
trailing, leading, bottom, top and height
Height is an outlet in my code. I'm checking how many lines are in the textView and based on that I'm modifying height:
func textViewDidChange(textView: UITextView) { //Handle the text changes here
switch(textView.numberOfLines()) {
case 1:
heightConstraint.constant = 38
break
case 2:
heightConstraint.constant = 50
break
case 3:
heightConstraint.constant = 70
break
case 4:
heightConstraint.constant = 90
break
default:
heightConstraint.constant = 90
break
}
}
The number of lines above is calculated by this extension:
extension UITextView{
func numberOfLines() -> Int{
if let fontUnwrapped = self.font{
return Int(self.contentSize.height / fontUnwrapped.lineHeight)
}
return 0
}
}
The initial height of the textView is 38.
The initial font size in the textView is 15.
Now, it works nice, when user starts typing new line, but the textView is not set within full bounds of the view. I mean by that the fact, that it looks like this:
and it should look like this:
Why there is this extra white space being added and how can I get rid of it?
Currently when new line appears there's this white space, but when user scrolls the textView to center the text and get rid of the white space - it is gone forever, user is not able to scroll it up again so the white line is there. So for me it looks like some problem with refreshing content, but maybe you know better - can you give me some hints?
Here is a bit different approach I use in the comment section of one of the apps I'm developing. This works very similar to Facebook Messenger iOS app's input field. Changed outlet names to match with the ones in your question.
//Height constraint outlet of view which contains textView.
#IBOutlet weak var heightConstraint: NSLayoutConstraint!
#IBOutlet weak var textView: UITextView!
//Maximum number of lines to grow textView before enabling scrolling.
let maxTextViewLines = 5
//Minimum height for textViewContainer (when there is no text etc.)
let minTextViewContainerHeight = 40
func textViewDidChange(textView: UITextView) {
let textViewVerticalInset = textView.textContainerInset.bottom + textView.textContainerInset.top
let maxHeight = ((textView.font?.lineHeight)! * maxTextViewLines) + textViewVerticalInset
let sizeThatFits = textView.sizeThatFits(CGSizeMake(textView.frame.size.width, CGFloat.max))
if sizeThatFits.height < minTextViewContainerHeight {
heightConstraint.constant = minTextViewContainerHeight
textView.scrollEnabled = false
} else if sizeThatFits.height < maxHeight {
heightConstraint.constant = sizeThatFits.height
textView.scrollEnabled = false
} else {
heightConstraint.constant = maxHeight
textView.scrollEnabled = true
}
}
func textViewDidEndEditing(textView: UITextView) {
textView.text = ""
heightConstraint.constant = minTextViewContainerHeight
textView.scrollEnabled = false
}
I'm using ASTextInputAccessoryView. It handles everything for you and is very easy to set up:
import ASTextInputAccessoryView
class ViewController: UIViewController {
var iaView: ASResizeableInputAccessoryView!
var messageView = ASTextComponentView()
override func viewDidLoad() {
super.viewDidLoad()
let photoComponent = UINib
.init(nibName: "PhotosComponentView", bundle: nil)
.instantiateWithOwner(self, options: nil)
.first as! PhotosComponentView
messageView = ASTextComponentView(frame: CGRect(x: 0, y: 0, width: screenSize.width , height: 44))
messageView.backgroundColor = UIColor.uIColorFromHex(0x191919)
messageView.defaultSendButton.addTarget(self, action: #selector(buttonAction), forControlEvents: .TouchUpInside)
iaView = ASResizeableInputAccessoryView(components: [messageView, photoComponent])
iaView.delegate = self
}
}
//MARK: Input Accessory View
extension ViewController {
override var inputAccessoryView: UIView? {
return iaView
}
// IMPORTANT Allows input view to stay visible
override func canBecomeFirstResponder() -> Bool {
return true
}
// Handle Rotation
override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)
coordinator.animateAlongsideTransition({ (context) in
self.messageView.textView.layoutIfNeeded()
}) { (context) in
self.iaView.reloadHeight()
}
}
}
// MARK: ASResizeableInputAccessoryViewDelegate
extension ViewController: ASResizeableInputAccessoryViewDelegate {
func updateInsets(bottom: CGFloat) {
var contentInset = tableView.contentInset
contentInset.bottom = bottom
tableView.contentInset = contentInset
tableView.scrollIndicatorInsets = contentInset
}
func inputAccessoryViewWillAnimateToHeight(view: ASResizeableInputAccessoryView, height: CGFloat, keyboardHeight: CGFloat) -> (() -> Void)? {
return { [weak self] in
self?.updateInsets(keyboardHeight)
self?.tableView.scrollToBottomContent(false)
}
}
func inputAccessoryViewKeyboardWillPresent(view: ASResizeableInputAccessoryView, height: CGFloat) -> (() -> Void)? {
return { [weak self] in
self?.updateInsets(height)
self?.tableView.scrollToBottomContent(false)
}
}
func inputAccessoryViewKeyboardWillDismiss(view: ASResizeableInputAccessoryView, notification: NSNotification) -> (() -> Void)? {
return { [weak self] in
self?.updateInsets(view.frame.size.height)
}
}
func inputAccessoryViewKeyboardDidChangeHeight(view: ASResizeableInputAccessoryView, height: CGFloat) {
let shouldScroll = tableView.isScrolledToBottom
updateInsets(height)
if shouldScroll {
self.tableView.scrollToBottomContent(false)
}
}
}
Now you just need to set up the actions for the buttons of the AccessoryView.
// MARK: Actions
extension ViewController {
func buttonAction(sender: UIButton!) {
// do whatever you like with the "send" button. for example post stuff to firebase or whatever
// messageView.textView.text <- this is the String inside the textField
messageView.textView.text = ""
}
#IBAction func dismissKeyboard(sender: AnyObject) {
self.messageView.textView.resignFirstResponder()
}
func addCameraButton() {
let cameraButton = UIButton(type: .Custom)
let image = UIImage(named: "camera")?.imageWithRenderingMode(.AlwaysTemplate)
cameraButton.setImage(image, forState: .Normal)
cameraButton.tintColor = UIColor.grayColor()
messageView.leftButton = cameraButton
let width = NSLayoutConstraint(
item: cameraButton,
attribute: .Width,
relatedBy: .Equal,
toItem: nil,
attribute: .NotAnAttribute,
multiplier: 1,
constant: 40
)
cameraButton.superview?.addConstraint(width)
cameraButton.addTarget(self, action: #selector(self.showPictures), forControlEvents: .TouchUpInside)
}
func showPictures() {
PHPhotoLibrary.requestAuthorization { (status) in
NSOperationQueue.mainQueue().addOperationWithBlock({
if let photoComponent = self.iaView.components[1] as? PhotosComponentView {
self.iaView.selectedComponent = photoComponent
photoComponent.getPhotoLibrary()
}
})
}
}
}
Related
I'm trying to build and integrate to a custom UIViewController with a tapRecognizer and an UITextView.
The issue I face is that the UITextView display part of the text off screen and I don't know what is causing this! I expect something simple or maybe including more classes but I haven't found anything pointing me in the right direction yet.
The size / position of a UITextView is working well when using UIViewRepresentable and a UITextView but not with a UIViewController / UIViewControllerRepresentable and a UITextView inside it for some reasons (see example bellow with both for comparison).
Here is the code of the test app I have:
import SwiftUI
import UIKit
struct TestingView: View {
var body: some View {
VStack {
WrappedUIViewController().padding()
Spacer()
WrappedUITextView(myText: "This is a long text to see if the word wrap work in this case better I hope so but I don't know. Is it? I hope it is, ! Do you? I do! Hope you do too, do you?").padding()
Spacer()
}
}
}
struct WrappedUITextView: UIViewRepresentable {
let myText: String
func makeUIView(context: Context) -> UITextViewPlus {
let view = UITextViewPlus()
view.isScrollEnabled = true
view.isEditable = false
view.isUserInteractionEnabled = true
view.font = UIFont(name: "Time New Roman", size: 20)
view.isOpaque = false
view.text = "WrappedUITextView \(myText)"
return view
}
func updateUIView(_ uiView: UITextViewPlus, context: Context) {}
}
struct WrappedUIViewController: UIViewControllerRepresentable {
typealias UIViewControllerType = CustomUIViewController
func makeUIViewController(context: Context) -> CustomUIViewController {
return CustomUIViewController()
}
func updateUIViewController(_ uiViewController: CustomUIViewController, context: Context) {}
}
class CustomUIViewController: UIViewController {
var textView: UITextView = UITextView()
var tapGesture: UITapGestureRecognizer!
override func viewDidLoad() {
super.viewDidLoad()
tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(recognizer:)))
self.textView.addGestureRecognizer(tapGesture)
self.textView.isEditable = false
self.textView.isSelectable = false
self.textView.clipsToBounds = true
self.textView.text = """
This is some text that will need to be displayed on multiple lines as it's longer than the screen size, will it wrapp correctly?
This is a great testing app
This is the end of this textView content
Here is a Potato
"""
self.textView.backgroundColor = UIColor.green
self.textView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.textView.textContainer.lineBreakMode = .byWordWrapping
view.addSubview(self.textView)
}
#objc func handleTap(recognizer: UITapGestureRecognizer) {
let location: CGPoint = recognizer.location(in: textView)
let position: CGPoint = CGPoint(x: location.x, y: location.y)
let tapPosition: UITextPosition = textView.closestPosition(to: position)!
guard let textRange: UITextRange = textView.tokenizer.rangeEnclosingPosition(tapPosition, with: UITextGranularity.word, inDirection: UITextDirection(rawValue: 1)) else { return }
let tappedWord: String = textView.text(in: textRange) ?? ""
print("tapped word -> \(tappedWord)")
}
override func viewWillLayoutSubviews() {
textView.sizeThatFits(self.view.bounds.size)
textView.sizeToFit()
}
}
The green textView is not wrapping the text properly or has a size that goes off screen for some reasons:
I'm new to swift and IOS so I would not be surprised if I do something wrong, any help is welcome!
EDIT: in case the code of the other file would help resolve this (I doubt it though):
import SwiftUI
#main
struct testAppApp: App {
var body: some Scene {
WindowGroup {
Text("Test App")
TestingView()
}
}
}
I have made changes to you CustomViewController class and added constraints to your textview to allow it to align properly. Methods and lines of code i have added are commented
You have to tell Auto Layout how you want your view to be aligned and positioned otherwise everything will be chaotic.
class CustomUIViewController: UIViewController {
var textView: UITextView = UITextView()
var tapGesture: UITapGestureRecognizer!
override func viewDidLoad() {
super.viewDidLoad()
tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(recognizer:)))
self.textView.addGestureRecognizer(tapGesture)
self.textView.isEditable = false
self.textView.isSelectable = false
self.textView.clipsToBounds = true
self.textView.text = """
This is some text that will need to be displayed on multiple lines as it's longer than the screen size, will it wrapp correctly?
This is a great testing app
This is the end of this textView content
Here is a Potato
"""
self.textView.backgroundColor = UIColor.green
self.textView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.textView.textContainer.lineBreakMode = .byWordWrapping
self.textView.translatesAutoresizingMaskIntoConstraints = false //ADDED: allows view to obey Auto Layout constraints.
view.addSubview(self.textView)
addConstraints() //ADDED: method for invoking constraints
}
func addConstraints(){
let margins = view.layoutMarginsGuide //safe area layout - esp for devices with notch
NSLayoutConstraint.activate([
textView.topAnchor.constraint(equalTo: margins.topAnchor), //textview's top anchor = that of parent view
textView.leadingAnchor.constraint(equalTo: margins.leadingAnchor),
textView.heightAnchor.constraint(equalToConstant: 200), //gave textview a constant height
textView.trailingAnchor.constraint(equalTo: margins.trailingAnchor)
])
}
#objc func handleTap(recognizer: UITapGestureRecognizer) {
let location: CGPoint = recognizer.location(in: textView)
let position: CGPoint = CGPoint(x: location.x, y: location.y)
let tapPosition: UITextPosition = textView.closestPosition(to: position)!
guard let textRange: UITextRange = textView.tokenizer.rangeEnclosingPosition(tapPosition, with: UITextGranularity.word, inDirection: UITextDirection(rawValue: 1)) else { return }
let tappedWord: String = textView.text(in: textRange) ?? ""
print("tapped word -> \(tappedWord)")
}
override func viewWillLayoutSubviews() {
textView.sizeThatFits(self.view.bounds.size)
textView.sizeToFit()
}
}
After multiple iterations I managed to get something that would work, actually 2 options to solve this:
Changing "view.addSubview(self.textView)" to "view. = self.textView" in viewDidLoad. I'm not sure what this change but with this I have the UITextview bound ajusted by the system.
override func viewDidLoad() {
super.viewDidLoad()
...
view = self.textView
}
the second and most likely "better" solution: set a frame to my UITextView inside viewDidLoad. When "textView.sizeToFit()" the height is then addapted while the width is maintained to what I wanted:
override func viewDidLoad() {
super.viewDidLoad()
...
self.textView.frame = CGRect(x: 0, y: 0, width: view.frame.width, height: 10000)
...
view.addSubview(self.textView)
}
I have a code where the user can input several ingredients and can also delete them.
Method: input ingredient in searchBar then create UIButton for that input and add into UIView. I also have a function to arrange position of all buttons properly
class SearchViewController: UIViewController {
var listInputView = UIView()
let spacingX: CGFloat = 10
let spacingY: CGFloat = 10
let btnHeight: CGFloat = 30
var heightConstraint: NSLayoutConstraint = NSLayoutConstraint()
var listBtn = [UIButton]()
//and also initialization of other components like UISearchBar,...
override func viewDidLoad() {
super.viewDidLoad()
setupSegmentControl()
setupView()
listInputView.translatesAutoresizingMaskIntoConstraints = false
heightConstraint = listInputView.heightAnchor.constraint(equalToConstant: 10)
}
func setupSegmentControl() {
//setup UISegmentedControl
}
fileprivate func setupView() {
//setup UISearchBar
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
if segmentControl.selectedSegmentIndex == 0 {
//search from input menu
}
else {
//search from input ingredients
inputSearchBar.append(mainSearchBar.text!)
listIngredients()
}
}
func listIngredients() {
view.addSubview(listInputView)
listInputView.isHidden = false
NSLayoutConstraint.activate([
listInputView.topAnchor.constraint(equalTo: mainSearchBar.bottomAnchor, constant: 10),
listInputView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
listInputView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
heightConstraint
])
createIngredientBtn(ingredient: mainSearchBar.text!)
}
// create a button
func createIngredientBtn(ingredient: String) {
let deleteIcon = UIImage(systemName: "multiply")
let btn = UIButton(type: UIButton.ButtonType.custom) as UIButton
btn.setTitle(ingredient, for: UIControl.State.normal)
btn.setTitleColor(UIColor(hexString: "#707070"),for: UIControl.State.normal)
btn.layer.borderColor = UIColor(hexString: "#707070").cgColor
btn.layer.borderWidth = 1.0
btn.frame.size.height = btnHeight
btn.layer.masksToBounds = true
btn.contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
btn.setImage(deleteIcon, for: .normal)
btn.imageView?.contentMode = .scaleAspectFit
btn.imageEdgeInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 3)
btn.semanticContentAttribute = UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft ? .forceLeftToRight : .forceRightToLeft
btn.addTarget(self, action: #selector(deleteSelectedInput), for: UIControl.Event.touchDown)
btn.translatesAutoresizingMaskIntoConstraints = false
listBtn.append(btn)
listInputView.addSubview(btn)
}
// delete a button
#objc func deleteSelectedInput(_ sender: UIButton) {
sender.removeFromSuperview()
listBtn = listBtn.filter {$0 != sender}
showListBtn() //I try to call this here to reposition other buttons
viewDidLayoutSubviews()
inputSearchBar = inputSearchBar.filter {$0 != sender.currentTitle}
if inputSearchBar.isEmpty {
listInputView.isHidden = true
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
showListBtn()
}
//Arrange position of each button and update height of inputView according to them
func showListBtn() {
let listInputViewWidth = listInputView.frame.size.width
var currentOriginX: CGFloat = 0
var currentOriginY: CGFloat = 0
listBtn.forEach { btn in
// break to newline
if currentOriginX + btn.frame.width > listInputViewWidth {
currentOriginX = 0
currentOriginY += btnHeight + spacingY
}
// set the btn frame origin
btn.frame.origin.x = currentOriginX
btn.frame.origin.y = currentOriginY
// increment current X
currentOriginX += btn.frame.width + spacingX
}
// update listInputView height
heightConstraint.constant = currentOriginY + btnHeight
}
}
Problem: Suppose I have > 1 input ingredients (> 1 button) and I delete one of it, the rest will be overlapped on each other at the leftmost origin (where 1st button is placed). At this moment, the position of them is actually correct, they just don't re-display as they should be.
However, when I try to input new ingredient, they will then display properly.
Here is the image to clarify this better:
Input 3 ingredients: Ing1, Ing2, Ing3
Delete Ing1. Then Ing2 and Ing3 are overlapped at the position of Ing1.
Add new input Ing4. They are all position correctly again.
Why the buttons are not re-position correctly when I delete one of them and how can I solve this ? Please kindly help me. Thank you.
EDIT: I'm able to call viewDidLayoutSubviews after delete but the buttons still not re-draw at new position although the frame.origin has changed correctly.
#objc func deleteSelectedInput(_ sender: UIButton) {
sender.removeFromSuperview()
listBtn = listBtn.filter {$0 != sender}
listInputView.removeAllSubviews()
listBtn.forEach { btn in
listInputView.addSubview(btn)
}
inputSearchBar = inputSearchBar.filter {$0 != sender.currentTitle}
if inputSearchBar.isEmpty {
listInputView.isHidden = true
}
}
After customizing the navigation bar height bigger than the default value (44pt), I want to change the height of my right side navigation bar item button, but it's limited in 44pt. How can I make it taller? I know that in iOS 11, the button now is inside a UIBarButtonStackView, it seems we cannot change the stack view frame?
I use this code to change the width and height of the button:
button.widthAnchor.constraint(equalToConstant: 40).isActive = true
button.heightAnchor.constraint(equalToConstant: 60).isActive = true
button.translatesAutoresizingMaskIntoConstraints = false
button.setImage(image, for: .normal)
let barButton = UIBarButtonItem(customView: button)
self.navigationItem.rightBarButtonItem = barButton
Thank you!
You can change the width of navigation bar button item by using this code -
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
var frame: CGRect? = navigationItem.leftBarButtonItem?.customView?.frame
frame?.size.width = 5 // change the width of your item bar button
self.navigationItem.leftBarButtonItem?.customView?.frame = frame!
}
override var prefersStatusBarHidden : Bool {
return true
}
Or from storyboard -
Make sure your Assets.xcassets image is set as Render As - Original Image Just like -
Using subclass of UInavigationcontroller class and NavigationBar class you can achieve this.
I am sharing some code of snipt:
class ARVNavigationController {
init(rootViewController: UIViewController) {
super.init(navigationBarClass: AVNavigationBar.self, toolbarClass: nil)
viewControllers = [rootViewController] }}
class AVNavigationBar {
let AVNavigationBarHeight: CGFloat = 80.0
init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
initialize()
}
init(frame: CGRect) {
super.init(frame: frame ?? CGRect.zero)
initialize()
}
func initialize() {
transform = CGAffineTransform(translationX: 0, y: +AVNavigationBarHeight)
}
func layoutSubviews() {
super.layoutSubviews()
let classNamesToReposition = ["_UINavigationBarBackground", "UINavigationItemView", "UINavigationButton"]
for view: UIView? in subviews() {
if classNamesToReposition.contains(NSStringFromClass(view.self)) {
let bounds: CGRect = self.bounds()
let frame: CGRect? = view?.frame
frame?.origin.y = bounds.origin.y + CGFloat(AVNavigationBarHeight)
frame?.size.height = bounds.size.height - 20.0
view?.frame = frame ?? CGRect.zero
}
}
}
func position(for bar: UIBarPositioning) -> UIBarPosition {
return .topAttached
}
}
I am experiencing strange behavior when animating the height of an input accessory view. What am I doing wrong?
I create a UIInputView subclass (InputView) with a single subview. The height of InputView and its intrinsicContentSize are controlled by the subview. InputView is 50 pixels tall when isVisible is true and 0 pixels tall when isVisible is false.
import UIKit
class InputView: UIInputView {
private let someHeight: CGFloat = 50.0, zeroHeight: CGFloat = 0.0
private let subView = UIView()
private var hide: NSLayoutConstraint?, show: NSLayoutConstraint?
var isVisible: Bool {
get {
return show!.isActive
}
set {
// Always deactivate constraints before activating conflicting ones
if newValue == true {
hide?.isActive = false
show?.isActive = true
} else {
show?.isActive = false
hide?.isActive = true
}
}
}
// MARK: Sizing
override func sizeThatFits(_ size: CGSize) -> CGSize {
return CGSize(width: size.width, height: someHeight)
}
override var intrinsicContentSize: CGSize {
return CGSize.init(width: bounds.size.width, height: subView.bounds.size.height)
}
// MARK: Initializers
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override init(frame: CGRect, inputViewStyle: UIInputViewStyle) {
super.init(frame: frame, inputViewStyle: inputViewStyle)
addSubview(subView)
subView.backgroundColor = UIColor.purple
translatesAutoresizingMaskIntoConstraints = false
subView.translatesAutoresizingMaskIntoConstraints = false
subView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor).isActive = true
subView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
subView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
subView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor).isActive = true
show = subView.heightAnchor.constraint(equalToConstant: someHeight)
hide = subView.heightAnchor.constraint(equalToConstant: zeroHeight)
hide?.isActive = true
}
}
The host view controller toggles isVisible in a one-second animation block when a button is pressed.
import UIKit
class MainViewController: UIViewController {
let testInputView = InputView.init(frame: .zero, inputViewStyle: .default)
#IBAction func button(_ sender: AnyObject) {
UIView.animate(withDuration: 1.0) {
let isVisible = self.testInputView.isVisible
self.testInputView.isVisible = !isVisible
self.testInputView.layoutIfNeeded()
}
}
override var canBecomeFirstResponder: Bool {
return true
}
override var inputAccessoryView: UIView? {
return testInputView
}
override func viewDidLoad() {
super.viewDidLoad()
}
}
I expect the input accessory view to smoothly grow from the botton of the screen when isVisible is set to true, and smoothly shrink to the button of the screen when isVisible is set to false. Instead, the keyboard background overlay appears at full 50-pixel height as soon as isVisible is true and the input accessory view grows from the center of its frame.
When shrinking, the input accessory view instantly loses some of its height before continuing the animation smoothly.
I created an input accessory view demonstration project that displays this unexpected behavior.
This will give you the correct animation:
UIView.animate(withDuration: 1.0) {
let isVisible = self.testInputView.isVisible
self.testInputView.isVisible = !isVisible
self.testInputView.superview?.superview?.layoutIfNeeded()
}
However, it's never a good practice to call superview if Apple changes the design. So there may be a better answer.
This is what the superviews represent:
print(testInputView.superview) // UIInputSetHostView
print(testInputView.superview?.superview) // UIInputSetContainerView
EDIT: ADDED A SAFER SOLUTION
I'm not too familiar with the UIInputView. But one way of solving it without calling the superview would be to only animate the height change of the subview:
Step 1:
Move the isVisible outside the animation block.
#IBAction func button(_ sender: AnyObject) {
let isVisible = self.testInputView.isVisible
self.testInputView.isVisible = !isVisible
UIView.animate(withDuration: 1.0) {
self.testInputView.layoutIfNeeded()
}
}
Step 2:
Create a new method in your InputView which changes the height constraint of the InputView instead of the intrinsicContentSize.
private func updateHeightConstraint(height: CGFloat) {
for constraint in constraints {
if constraint.firstAttribute == .height {
constraint.constant = height
}
}
self.layoutIfNeeded()
}
Step 3:
And call that method inside the setter.
if newValue == true {
updateHeightConstraint(height: someHeight)
hide?.isActive = false
show?.isActive = true
} else {
updateHeightConstraint(height: zeroHeight)
show?.isActive = false
hide?.isActive = true
}
Step 4:
Lastly some changes in the init.
override init(frame: CGRect, inputViewStyle: UIInputViewStyle) {
super.init(frame: frame, inputViewStyle: inputViewStyle)
addSubview(subView)
backgroundColor = .clear
subView.backgroundColor = UIColor.purple
subView.translatesAutoresizingMaskIntoConstraints = false
subView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
subView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
subView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor).isActive = true
show = subView.heightAnchor.constraint(equalToConstant: someHeight)
hide = subView.heightAnchor.constraint(equalToConstant: zeroHeight)
hide?.isActive = true
}
Conclusion:
This result in the InputView changes it's height before animating the height of the purple subview. The only downside is the UIInputView, which has some kind of gray background as default and cannot be changed to Clear. However, you can use the same backgroundColor as the VC.
But if you instead should go with a regular UIView as InputAccessoryView it will be UIColor.clear as default. Than the first "jump" will not be noticed.
I've implemented a UIScrollView within a UITableViewCell that enables the user to scroll left and right to reveal buttons in the same fashion as the iOS Mail app. The original implementation that set frames and positions explicitly worked well but I've refactored the code to use autolayout throughout. Animation to hide/reveal the 'container' for the buttons on the left (accessory buttons) works well but the animation that brings the scrollview to rest when the right container (edit buttons) slows just before reaching the desired offset before jerking into its final position.
All calculations use the same math just transformed (e.g. + rather than - value, > rather than < in tests) depending on the side the container is located and the values displayed by logging are correct. I can't see any obvious code errors and there are no constraints for the cells set up in IB. Is this a bug or is there something obvious I've missed through staring at the code for the last hour?
class SwipeyTableViewCell: UITableViewCell {
// MARK: Constants
private let thresholdVelocity = CGFloat(0.6)
private let maxClosureDuration = CGFloat(40)
// MARK: Properties
private var buttonContainers = [ButtonContainerType: ButtonContainer]()
private var leftContainerWidth: CGFloat {
return buttonContainers[.Accessory]?.containerWidthWhenOpen ?? CGFloat(0)
}
private var rightContainerWidth: CGFloat {
return buttonContainers[.Edit]?.containerWidthWhenOpen ?? CGFloat(0)
}
private var buttonContainerRightAnchor = NSLayoutConstraint()
private var isOpen = false
// MARK: Subviews
private let scrollView = UIScrollView()
// MARK: Lifecycle methods
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
scrollView.delegate = self
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
contentView.addSubview(scrollView)
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.topAnchor.constraintEqualToAnchor(contentView.topAnchor).active = true
scrollView.leftAnchor.constraintEqualToAnchor(contentView.leftAnchor).active = true
scrollView.rightAnchor.constraintEqualToAnchor(contentView.rightAnchor).active = true
scrollView.bottomAnchor.constraintEqualToAnchor(contentView.bottomAnchor).active = true
let scrollContentView = UIView()
scrollContentView.backgroundColor = UIColor.cyanColor()
scrollView.addSubview(scrollContentView)
scrollContentView.translatesAutoresizingMaskIntoConstraints = false
scrollContentView.topAnchor.constraintEqualToAnchor(scrollView.topAnchor).active = true
scrollContentView.leftAnchor.constraintEqualToAnchor(scrollView.leftAnchor).active = true
scrollContentView.rightAnchor.constraintEqualToAnchor(scrollView.rightAnchor).active = true
scrollContentView.bottomAnchor.constraintEqualToAnchor(scrollView.bottomAnchor).active = true
scrollContentView.widthAnchor.constraintEqualToAnchor(contentView.widthAnchor, constant: 10).active = true
scrollContentView.heightAnchor.constraintEqualToAnchor(contentView.heightAnchor).active = true
buttonContainers[.Accessory] = ButtonContainer(type: .Accessory, scrollContentView: scrollContentView)
buttonContainers[.Edit] = ButtonContainer(type: .Edit, scrollContentView: scrollContentView)
for bc in buttonContainers.values {
scrollContentView.addSubview(bc)
bc.widthAnchor.constraintEqualToAnchor(contentView.widthAnchor).active = true
bc.heightAnchor.constraintEqualToAnchor(scrollContentView.heightAnchor).active = true
bc.topAnchor.constraintEqualToAnchor(scrollContentView.topAnchor).active = true
bc.containerToContentConstraint.active = true
}
scrollView.contentInset = UIEdgeInsetsMake(0, leftContainerWidth, 0, rightContainerWidth)
}
func closeContainer() {
scrollView.contentOffset.x = CGFloat(0)
}
}
extension SwipeyTableViewCell: UIScrollViewDelegate {
func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
let xOffset: CGFloat = scrollView.contentOffset.x
isOpen = false
for bc in buttonContainers.values {
if bc.isContainerOpen(xOffset, thresholdVelocity: thresholdVelocity, velocity: velocity) {
targetContentOffset.memory.x = bc.offsetRequiredToOpenContainer()
NSLog("Target offset \(targetContentOffset.memory.x)")
isOpen = true
break /// only one container can be open at a time so cn exit here
}
}
if !isOpen {
NSLog("Closing container")
targetContentOffset.memory.x = CGFloat(0)
let ms: CGFloat = xOffset / velocity.x /// if the scroll isn't on a fast path to zero, animate it closed
if (velocity.x == 0 || ms < 0 || ms > maxClosureDuration) {
NSLog("Animating closed")
dispatch_async(dispatch_get_main_queue()) {
scrollView.setContentOffset(CGPointZero, animated: true)
}
}
}
}
/**
Defines the position of the container view for buttons assosicated with a SwipeyTableViewCell
- Edit: Identifier for a UIView that acts as a container for buttons to the right of the cell
- Accessory: Identifier for a UIView that acts as a container for buttons to the left of the vell
*/
enum ButtonContainerType {
case Edit, Accessory
}
extension ButtonContainerType {
func getConstraints(scrollContentView: UIView, buttonContainer: UIView) -> NSLayoutConstraint {
switch self {
case Edit:
return buttonContainer.leftAnchor.constraintEqualToAnchor(scrollContentView.rightAnchor)
case Accessory:
return buttonContainer.rightAnchor.constraintGreaterThanOrEqualToAnchor(scrollContentView.leftAnchor)
}
}
func containerOpenedTest() -> ((scrollViewOffset: CGFloat, containerFullyOpenWidth: CGFloat, thresholdVelocity: CGFloat, velocity: CGPoint) -> Bool) {
switch self {
case Edit:
return {(scrollViewOffset: CGFloat, containerFullyOpenWidth: CGFloat, thresholdVelocity: CGFloat, velocity: CGPoint) -> Bool in
(scrollViewOffset > containerFullyOpenWidth || (scrollViewOffset > 0 && velocity.x > thresholdVelocity))
}
case Accessory:
return {(scrollViewOffset: CGFloat, containerFullyOpenWidth: CGFloat, thresholdVelocity: CGFloat, velocity: CGPoint) -> Bool in
(scrollViewOffset < -containerFullyOpenWidth || (scrollViewOffset < 0 && velocity.x < -thresholdVelocity))
}
}
}
func transformOffsetForContainerSide(containerWidthWhenOpen: CGFloat) -> CGFloat {
switch self {
case Edit:
return containerWidthWhenOpen
case Accessory:
return -containerWidthWhenOpen
}
}
}
/// A UIView subclass that acts as a container for buttongs associated with a SwipeyTableCellView
class ButtonContainer: UIView {
private let scrollContentView: UIView
private let type: ButtonContainerType
private let maxNumberOfButtons = 3
let buttonWidth = CGFloat(65)
private var buttons = [UIButton]()
var containerWidthWhenOpen: CGFloat {
// return CGFloat(buttons.count) * buttonWidth
return buttonWidth // TODO: Multiple buttons not yet implements - this will cause a bug!!
}
var containerToContentConstraint: NSLayoutConstraint {
return type.getConstraints(scrollContentView, buttonContainer: self)
}
var offsetFromContainer = CGFloat(0) {
didSet {
let delta = abs(oldValue - offsetFromContainer)
containerToContentConstraint.constant = offsetFromContainer
if delta > (containerWidthWhenOpen * 0.5) { /// this number is arbitary - can it be more formal?
animateConstraintWithDuration(0.1, delay: 0, options: UIViewAnimationOptions.CurveEaseOut, completion: nil) /// ensure large changes are animated rather than snapped
}
}
}
// MARK: Initialisers
init(type: ButtonContainerType, scrollContentView: UIView) {
self.type = type
self.scrollContentView = scrollContentView
super.init(frame: CGRectZero)
backgroundColor = UIColor.blueColor()
translatesAutoresizingMaskIntoConstraints = false
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Public methods
func isContainerOpen(scrollViewOffset: CGFloat, thresholdVelocity: CGFloat, velocity: CGPoint) -> Bool {
let closure = type.containerOpenedTest()
return closure(scrollViewOffset: scrollViewOffset, containerFullyOpenWidth: containerWidthWhenOpen, thresholdVelocity: thresholdVelocity, velocity: velocity)
}
func offsetRequiredToOpenContainer() -> CGFloat {
return type.transformOffsetForContainerSide(containerWidthWhenOpen)
}
}
OK - found the error and it was a typo left from earlier experimentation with UIScrollView. The clue was in my earlier comment about the 'snap' occurring within 10pt of the desired targetContentOffset...
The scrollContentView width constraint was set incorrectly as follows:
scrollContentView.widthAnchor.constraintEqualToAnchor(contentView.widthAnchor, constant: 10).active = true
Before I found out that I could force a UIScrollView to scroll by setting its contentInset, I just made the subview larger that the cell's contentView that the UIScrollView was pinned to. As I've been refactoring code verbatim to use the new anchor properties, the old code propagated and I got my bug!
So, not iOS at fault...just me not paying attention. Lesson learnt! I now have some other ideas on how to implement things that might be a little tidier.