Search code examples
uiscrollviewuikit

How to make horizontal UIScrollView with dynamic width?


I have a label that dynamically increases the width. I decided to add it to the ScrollView (so that the user can see any length of text in the label).

But I ran into a problem. What is the correct way to implement this? How am I trying to do it. How to make horizontal scroll view with dynamic width?

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        scrollView.contentSize = .init(width: label.frame.width, height: label.frame.height)
    }
    
    func setupScrollView() {
        view.addSubview(scrollView)
        scrollView.addSubview(label)
        
        let frameLayoutGuide = scrollView.frameLayoutGuide
        
        NSLayoutConstraint.activate([
            frameLayoutGuide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 400),
            frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 100),
            frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -100),
            frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -300),
        ])
        
        let contentLayoutGuide = scrollView.contentLayoutGuide
        
        NSLayoutConstraint.activate([
            contentLayoutGuide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 400),
            contentLayoutGuide.centerXAnchor.constraint(equalTo: frameLayoutGuide.centerXAnchor),
            contentLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -300),

            label.centerXAnchor.constraint(equalTo: contentLayoutGuide.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: contentLayoutGuide.centerYAnchor)
        ])
    }

Solution

  • Sorry, but you're very mistaken about the use of scrollView.contentLayoutGuide and scrollView.frameLayoutGuide.

    You want to constrain the scrollView itself to its superview (the main view in this case) and constrain the scroll view's subview(s) to the scrollView's .contentLayoutGuide. You can then use the scrollView's .frameLayoutGuide to help set the size(s) of its subview(s).

    So, if you want a label centered in the scroll view, but then enable horizontal scrolling when the label is too wide to fit, you want to:

    • add your label to a "holder" view, constrained to the center
    • add that "holder" view to the scroll view
      • constraining its position to the .contentLayoutGuide and
      • constraining its size to the .frameLayoutGuide
    • add leading and trailing constraints to the label to force the "holder" view to grow when needed

    Here's a quick example. It will cycle through different length strings for the label... scrolling will automatically be enabled or disabled, based on the resulting width of the label (all with auto-layout -- no size calculations needed):

    class ViewController: UIViewController {
        
        let scrollView = UIScrollView()
        let label = UILabel()
        let labelHolderView = UIView()
    
        let sampleStrings: [String] = [
            "Short (no scrolling).",
            "A little longer (still no scrolling).",
            "A much longer string, that will definitely require horizontal scrolling.",
            "Just for kicks, let's make this string really, really long, to help demonstrate the benefits of using auto-layout!",
        ]
        
        var stringIndex: Int = 0
        
        override func viewDidLoad() {
            super.viewDidLoad()
            setupScrollView()
        }
        
        func setupScrollView() {
            
            // add the label to the holder view
            labelHolderView.addSubview(label)
            
            // add the holder view to the scroll view
            scrollView.addSubview(labelHolderView)
            
            // add the scroll view to the view
            view.addSubview(scrollView)
    
            // all three need this
            labelHolderView.translatesAutoresizingMaskIntoConstraints = false
            label.translatesAutoresizingMaskIntoConstraints = false
            scrollView.translatesAutoresizingMaskIntoConstraints = false
    
            let safeG = view.safeAreaLayoutGuide
            let contentG = scrollView.contentLayoutGuide
            let frameG = scrollView.frameLayoutGuide
            
            NSLayoutConstraint.activate([
                
                // let's put a 100-pt tall scroll view
                //  40-pts from the bottom
                //  40-pts on each side
                scrollView.bottomAnchor.constraint(equalTo: safeG.bottomAnchor, constant: -40.0),
                scrollView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor, constant: 40.0),
                scrollView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor, constant: -40.0),
                scrollView.heightAnchor.constraint(equalToConstant: 100.0),
    
                // constrain holder view to ContentGuide with Zero on all 4 sides
                labelHolderView.topAnchor.constraint(equalTo: contentG.topAnchor, constant: 0.0),
                labelHolderView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor, constant: 0.0),
                labelHolderView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor, constant: 0.0),
                labelHolderView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor, constant: 0.0),
    
                // we only want horizontal scrolling, so constrain
                //  holder view height to sccroll view FrameGuide
                labelHolderView.heightAnchor.constraint(equalTo: frameG.heightAnchor),
                
                // we want the label centered horizontally
                //  in the holder view and in the scroll view
                //  so set holder view width >= FrameGuide
                labelHolderView.widthAnchor.constraint(greaterThanOrEqualTo: frameG.widthAnchor, constant: 0.0),
                
                // center the label in the holder view
                label.centerXAnchor.constraint(equalTo: labelHolderView.centerXAnchor),
                label.centerYAnchor.constraint(equalTo: labelHolderView.centerYAnchor),
                
                // as the label expands horizontally, we want at least
                //  8-pts on each side
                label.leadingAnchor.constraint(greaterThanOrEqualTo: labelHolderView.leadingAnchor, constant: 8.0),
                label.trailingAnchor.constraint(lessThanOrEqualTo: labelHolderView.trailingAnchor, constant: -8.0),
                
            ])
            
            // let's use some background colors so we can see the view frames
            scrollView.backgroundColor = .yellow
            labelHolderView.backgroundColor = .systemTeal
            label.backgroundColor = .green
            
            // add a button above the scroll view to cycle through our sample strings
            let b = UIButton()
            b.setTitle("Tap Me", for: [])
            b.setTitleColor(.white, for: .normal)
            b.setTitleColor(.lightGray, for: .highlighted)
            b.backgroundColor = .systemGreen
            b.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(b)
            NSLayoutConstraint.activate([
                b.bottomAnchor.constraint(equalTo: scrollView.topAnchor, constant: -20.0),
                b.widthAnchor.constraint(equalTo: safeG.widthAnchor, multiplier: 0.6),
                b.centerXAnchor.constraint(equalTo: safeG.centerXAnchor),
            ])
            
            b.addTarget(self, action: #selector(gotTap(_:)), for: .touchUpInside)
            
            // set the initial text
            gotTap(nil)
            
        }
    
        @objc func gotTap(_ sender: Any?) -> Void {
            label.text = sampleStrings[stringIndex % sampleStrings.count]
            stringIndex += 1
        }
        
    }