Search code examples
iosswiftuiscrollview

Label and TextView overflows Scroll View horizontally


I have a Scroll View, in which I have a Stack View. In the Stack View I have arranged subviews of either UITextView or UILabel elements. All is done programmatically, without storyboard.

The Scroll View appears and I can scroll it nicely. But unfortunately it scrolls not only vertically (top to bottom) but also horizontally (to the right, out the screen) which I don't want to (this is the reason I have numberOfLines set on the UILabel too, tried to set equal width to the scroll and stack views as the stack view's left/right attributes are connected to the view).

If it's important, this function is called either in viewDidLoad or upon touching a button later.

    scrollView = UIScrollView()
    scrollView.translatesAutoresizingMaskIntoConstraints = false
    view.addSubview(scrollView)
    let leftConstraintScroll = NSLayoutConstraint(item: scrollView, attribute: .left, relatedBy: .equal, toItem: view, attribute: .left, multiplier: 1, constant: 0)
    let rightConstraintScroll = NSLayoutConstraint(item: scrollView, attribute: .right, relatedBy: .equal, toItem: view, attribute: .right, multiplier: 1, constant: 0)
    let topConstraintScroll = NSLayoutConstraint(item: scrollView, attribute: .top, relatedBy: .equal, toItem: selectedTabIndicator, attribute: .bottom, multiplier: 1, constant: 10)
    let bottomConstraintScroll = NSLayoutConstraint(item: scrollView, attribute: .bottom, relatedBy: .equal, toItem: editButton, attribute: .top, multiplier: 1, constant: 0)
    view.addConstraints([leftConstraintScroll, rightConstraintScroll, topConstraintScroll, bottomConstraintScroll])

    stackView = UIStackView()
    stackView.translatesAutoresizingMaskIntoConstraints = false
    stackView.axis = .vertical
    stackView.spacing = 10
    stackView.isLayoutMarginsRelativeArrangement = true
    stackView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 5, leading: 10, bottom: 5, trailing: 10)

    // Several elements are added like this (UITextView):
    let textView = UITextView()
    textView.translatesAutoresizingMaskIntoConstraints = false
    textView.delegate = self
    textView.isScrollEnabled = false
    textView.font = UIFont.systemFont(ofSize: 15)
    textView.backgroundColor = Constants.COLOR_P
    textView.textColor = .black
    textView.text = "XXX"
    stackView.addArrangedSubview(textView)

    // Or UILabel:
    var label = UILabel()
    label.translatesAutoresizingMaskIntoConstraints = false
    label.numberOfLines = 0
    label.textAlignment = .justified
    label.textColor = .black
    label.font = UIFont.systemFont(ofSize: 15)
    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.alignment = .justified
    paragraphStyle.hyphenationFactor = 1.0
    paragraphStyle.firstLineHeadIndent = 0
    paragraphStyle.headIndent = 15
    let hyphenAttribute = [NSAttributedString.Key.paragraphStyle: paragraphStyle]
    let attributedString = NSMutableAttributedString(string: "XXXXX", attributes: hyphenAttribute)
    label.attributedText = attributedString
    stackView.addArrangedSubview(label)
    
    scrollView.addSubview(stackView)
    let leftConstraint = NSLayoutConstraint(item: stackView, attribute: .left, relatedBy: .equal, toItem: scrollView, attribute: .left, multiplier: 1, constant: 0)
    let rightConstraint = NSLayoutConstraint(item: stackView, attribute: .right, relatedBy: .equal, toItem: scrollView, attribute: .right, multiplier: 1, constant: 0)
    let topConstraint = NSLayoutConstraint(item: stackView, attribute: .top, relatedBy: .equal, toItem: scrollView, attribute: .top, multiplier: 1, constant: 0)
    let bottomConstraint = NSLayoutConstraint(item: stackView, attribute: .bottom, relatedBy: .equal, toItem: scrollView, attribute: .bottom, multiplier: 1, constant: 0)
    scrollView.addConstraints([leftConstraint, rightConstraint, topConstraint, bottomConstraint, bottomConstraint])

Note: selectedTabIndicator and editButton are above and below the scroll view respectively.


Solution

  • First note: when posting code, post some actual code. Your code refers to scrollView and recipeScrollView which, I assume, are the same scroll view. Also, try to post complete information - your code also refers to selectedTabIndicator and editButton, neither of which have been identified nor described in your question.

    Second note: start using more modern constraint syntax. For example:

    NSLayoutConstraint.activate([
        recipeScrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0),
        recipeScrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0),
        recipeScrollView.topAnchor.constraint(equalTo: selectedTabIndicator.bottomAnchor, constant: 10.0),
        recipeScrollView.bottomAnchor.constraint(equalTo: editButton.topAnchor, constant: 0.0),
    ])
    

    is much easier to use (and to read) than:

    let leftConstraintScroll = NSLayoutConstraint(item: recipeScrollView, attribute: .left, relatedBy: .equal, toItem: view, attribute: .left, multiplier: 1, constant: 0)
    let rightConstraintScroll = NSLayoutConstraint(item: recipeScrollView, attribute: .right, relatedBy: .equal, toItem: view, attribute: .right, multiplier: 1, constant: 0)
    let topConstraintScroll = NSLayoutConstraint(item: recipeScrollView, attribute: .top, relatedBy: .equal, toItem: selectedTabIndicator, attribute: .bottom, multiplier: 1, constant: 10)
    let bottomConstraintScroll = NSLayoutConstraint(item: recipeScrollView, attribute: .bottom, relatedBy: .equal, toItem: editButton, attribute: .top, multiplier: 1, constant: 0)
    view.addConstraints([leftConstraintScroll, rightConstraintScroll, topConstraintScroll, bottomConstraintScroll])
    

    Third note: respect the Safe Area... so your leading constraint should be:

    recipeScrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 0.0)
    

    and so on.

    Fourth note: constrain your scroll view's content to the .contentLayoutGuide, not to the scroll view itself.

    To solve your "horizontal scrolling" issue, instead of setting the label and textView widths, set the width of the stack view relative to the scroll view's .frameLayoutGuide:

    stackView.widthAnchor.constraint(equalTo: recipeScrollView.frameLayoutGuide.widthAnchor, constant: 0.0)
    

    Here is your code, edited with those tips. I put a blue view near the top to be the selectedTabIndicator and a blue button near the bottom to be the editButton:

    class AnotherScrollViewController: UIViewController, UITextViewDelegate {
        
        var recipeScrollView: UIScrollView!
        var stackView: UIStackView!
        var textView: UITextView!
    
        var selectedTabIndicator: UIView!
        var editButton: UIButton!
        
        var editButtonBottom: NSLayoutConstraint!
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            selectedTabIndicator = UIView()
            selectedTabIndicator.backgroundColor = .blue
            selectedTabIndicator.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(selectedTabIndicator)
            
            editButton = UIButton()
            editButton.backgroundColor = .blue
            editButton.setTitle("Edit", for: [])
            editButton.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(editButton)
            
            recipeScrollView = UIScrollView()
            recipeScrollView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(recipeScrollView)
            
            let g = view.safeAreaLayoutGuide
            
            editButtonBottom = editButton.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0)
    
            NSLayoutConstraint.activate([
                
                selectedTabIndicator.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                selectedTabIndicator.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
                selectedTabIndicator.widthAnchor.constraint(equalToConstant: 200.0),
                selectedTabIndicator.heightAnchor.constraint(equalToConstant: 4.0),
                
                //editButton.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 4.0),
                editButtonBottom,
                editButton.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                
                recipeScrollView.topAnchor.constraint(equalTo: selectedTabIndicator.bottomAnchor, constant: 10.0),
                recipeScrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                recipeScrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                recipeScrollView.bottomAnchor.constraint(equalTo: editButton.topAnchor, constant: 0.0),
    
            ])
    
            stackView = UIStackView()
            stackView.translatesAutoresizingMaskIntoConstraints = false
            stackView.axis = .vertical
            stackView.spacing = 10
            stackView.isLayoutMarginsRelativeArrangement = true
            stackView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 5, leading: 10, bottom: 5, trailing: 10)
            
            // Several elements are added like this (UITextView):
            textView = UITextView()
            textView.translatesAutoresizingMaskIntoConstraints = false
            textView.delegate = self
            textView.isScrollEnabled = false
            textView.font = UIFont.systemFont(ofSize: 15)
            textView.backgroundColor = .cyan // Constants.COLOR_P
            textView.textColor = .black
            textView.text = "XXX"
            stackView.addArrangedSubview(textView)
            
            // Or UILabel:
            var label = UILabel()
            label.translatesAutoresizingMaskIntoConstraints = false
            label.numberOfLines = 0
            label.textAlignment = .justified
            label.backgroundColor = .green  // so we can easily see the label frame
            label.textColor = .black
            label.font = UIFont.systemFont(ofSize: 15)
            let paragraphStyle = NSMutableParagraphStyle()
            paragraphStyle.alignment = .justified
            paragraphStyle.hyphenationFactor = 1.0
            paragraphStyle.firstLineHeadIndent = 0
            paragraphStyle.headIndent = 15
            let hyphenAttribute = [NSAttributedString.Key.paragraphStyle: paragraphStyle]
            let labelString = "This is the string for the label. It will wrap if it is too long to fit in the allocated width."
            //let attributedString = NSMutableAttributedString(string: "XXXXX", attributes: hyphenAttribute)
            let attributedString = NSMutableAttributedString(string: labelString, attributes: hyphenAttribute)
            label.attributedText = attributedString
            stackView.addArrangedSubview(label)
    
            recipeScrollView.addSubview(stackView)
    
            let contentG = recipeScrollView.contentLayoutGuide
            let frameG = recipeScrollView.frameLayoutGuide
            
            NSLayoutConstraint.activate([
                
                stackView.topAnchor.constraint(equalTo: contentG.topAnchor, constant: 0.0),
                stackView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor, constant: 0.0),
                stackView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor, constant: 0.0),
                stackView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor, constant: 0.0),
    
                stackView.widthAnchor.constraint(equalTo: frameG.widthAnchor, constant: 0.0),
                
            ])
    
            recipeScrollView.backgroundColor = .red
            
            let notificationCenter = NotificationCenter.default
            notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillHideNotification, object: nil)
            notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
            
            editButton.addTarget(self, action: #selector(self.editButtonTapped), for: .touchUpInside)
        }
    
        @objc func editButtonTapped() -> Void {
            if textView.isFirstResponder {
                textView.resignFirstResponder()
            } else {
                textView.becomeFirstResponder()
            }
        }
        
        @objc func adjustForKeyboard(notification: Notification) {
            guard let keyboardValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return }
            
            let keyboardScreenEndFrame = keyboardValue.cgRectValue
            let keyboardViewEndFrame = view.convert(keyboardScreenEndFrame, from: view.window)
            print(keyboardViewEndFrame.height)
            var c: CGFloat = -4.0
            if notification.name != UIResponder.keyboardWillHideNotification {
                c -= (keyboardViewEndFrame.height - view.safeAreaInsets.bottom)
            }
            
            editButtonBottom.constant = c
            
            editButton.setTitle(c == -4 ? "Edit" : "Done", for: [])
        }
        
    }