Search code examples
iosswifttabsuiscrollviewstack

Handle selected UIView in scrollableStack


I have created some tabs. The tabs scroll inside the stack horizontaly as expected.

I am trying to add a bottom line (UIView of height = 2.0) to the selected tab (please see image below), and animate the transition of the bottom line as a new tab is selected. enter image description here

Here is my code:

      UIView.animate(withDuration: 0.5, animations: {
        let center = viewSelected.center        
        let xPointToMove = center.x

        self.animatedView.transform = CGAffineTransform(translationX: xPointToMove, y: 0.0)
      })

The problem is, that if the user taps the second tab, animatedView only moves a few pixels to the right. I realized that the center.x point of "viewSelected != animatedView" after the animation happens.

Any input on this is appreciated!


Solution

  • The problem is that your "tab" views are subviews of a UIStackView ... so their coordinate system (frame, center, etc) is relative to their positions in the stack view.

    You need to convert the coordinates.

    Try it like this:

        // get a reference to the stack view holding viewSelected
        // and a reference to the stack view's superView
        guard let stackV = viewSelected.superview as? UIStackView,
              let stackSV = stackV.superview
        else {
            return
        }
        // convert the frame of viewSelected from the stack view to its superView
        let r = stackV.convert(viewSelected.frame, to: stackSV)
        // animatedView center.x will be the converted frame's .midX
        let xPointToMove = r.midX
        UIView.animate(withDuration: 0.5, animations: {
            // animate the view itself, instead of Transforming its layer
            self.animatedView.center.x = xPointToMove
        })
    

    Edit - you may find it easier to use auto-layout constraints instead of setting the frame of the animated view. In addition, using constraints will help keep the animated view positioned and sized correctly if/when the overall view changes size.

    Here's a complete example to play with:

    class MyCustomTabView: UIView {
        
        var selected: Bool = false {
            didSet {
                label.textColor = selected ? tintSelectedColor : tintNormalColor
                imgView.tintColor = selected ? tintSelectedColor : tintNormalColor
                backgroundColor = selected ? bkgSelectedColor : bkgNormalColor
            }
        }
        
        let stack = UIStackView()
        let imgView = UIImageView()
        let label = UILabel()
    
        let bkgNormalColor: UIColor = #colorLiteral(red: 0.9625813365, green: 0.9627193809, blue: 0.9625510573, alpha: 1)
        let bkgSelectedColor: UIColor = #colorLiteral(red: 0.9008318782, green: 0.8487855792, blue: 0.9591421485, alpha: 1)
        let tintNormalColor: UIColor = #colorLiteral(red: 0.4399604499, green: 0.4400276542, blue: 0.4399457574, alpha: 1)
        let tintSelectedColor: UIColor = #colorLiteral(red: 0.3831990361, green: 0.0002554753446, blue: 0.9317755103, alpha: 1)
    
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        func commonInit() -> Void {
            stack.translatesAutoresizingMaskIntoConstraints = false
            stack.spacing = 8
            addSubview(stack)
            stack.addArrangedSubview(imgView)
            stack.addArrangedSubview(label)
            NSLayoutConstraint.activate([
                stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12.0),
                stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12.0),
                stack.centerYAnchor.constraint(equalTo: centerYAnchor),
                imgView.heightAnchor.constraint(equalTo: imgView.widthAnchor),
            ])
            label.font = .boldSystemFont(ofSize: 14.0)
            label.textColor = tintNormalColor
            imgView.tintColor = tintNormalColor
            backgroundColor = bkgNormalColor
        }
        
    }
    
    
    class MyCustomTabBarView: UIView {
        let stack = UIStackView()
        let animatedView = UIView()
        var avWidthConstraint: NSLayoutConstraint!
        var avCenterXConstraint: NSLayoutConstraint!
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        func commonInit() -> Void {
            animatedView.translatesAutoresizingMaskIntoConstraints = false
            stack.translatesAutoresizingMaskIntoConstraints = false
            addSubview(stack)
            addSubview(animatedView)
            
            NSLayoutConstraint.activate([
                stack.leadingAnchor.constraint(equalTo: leadingAnchor),
                stack.trailingAnchor.constraint(equalTo: trailingAnchor),
                stack.heightAnchor.constraint(equalTo: heightAnchor),
                stack.centerYAnchor.constraint(equalTo: centerYAnchor),
                
                animatedView.bottomAnchor.constraint(equalTo: stack.bottomAnchor),
                animatedView.heightAnchor.constraint(equalToConstant: 2.0),
            ])
    
            animatedView.frame = CGRect(origin: .zero, size: CGSize(width: 100, height: 2))
            
            let names: [String] = [
                "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX",
            ]
            
            for (sName, i) in zip(names, Array(1...names.count)) {
                let v = MyCustomTabView()
                v.label.text = "TAB " + sName
                v.imgView.image = UIImage(systemName: "\(i).circle")
                let t = UITapGestureRecognizer(target: self, action: #selector(tabTapped(_:)))
                v.addGestureRecognizer(t)
                stack.addArrangedSubview(v)
            }
    
            // position animatedView at tab 0
            if let v = stack.arrangedSubviews.first as? MyCustomTabView {
                avWidthConstraint = animatedView.widthAnchor.constraint(equalTo: v.widthAnchor)
                avCenterXConstraint = animatedView.centerXAnchor.constraint(equalTo: v.centerXAnchor)
                avWidthConstraint.isActive = true
                avCenterXConstraint.isActive = true
            }
    
            animatedView.backgroundColor = #colorLiteral(red: 0.3831990361, green: 0.0002554753446, blue: 0.9317755103, alpha: 1)
    
        }
        
        @objc func tabTapped(_ g: UITapGestureRecognizer) -> Void {
            guard let viewSelected = g.view as? MyCustomTabView,
                  let n = stack.arrangedSubviews.firstIndex(of: viewSelected)
            else {
                return
            }
            selectTab(n)
        }
        
        // so we can select a tab programmatically from the controller
        func selectTab(_ idx: Int) -> Void {
            // make sure we're not trying to select a tab that doesn't exists
            guard idx > -1, idx < stack.arrangedSubviews.count,
                  let viewSelected = stack.arrangedSubviews[idx] as? MyCustomTabView
            else {
                return
            }
    
            stack.arrangedSubviews.forEach { v in
                if let vv = v as? MyCustomTabView {
                    vv.selected = vv == viewSelected
                }
            }
            if avWidthConstraint != nil {
                avWidthConstraint.isActive = false
                avCenterXConstraint.isActive = false
            }
            avWidthConstraint = animatedView.widthAnchor.constraint(equalTo: viewSelected.widthAnchor)
            avCenterXConstraint = animatedView.centerXAnchor.constraint(equalTo: viewSelected.centerXAnchor)
            avWidthConstraint.isActive = true
            avCenterXConstraint.isActive = true
            UIView.animate(withDuration: 0.5, animations: {
                self.layoutIfNeeded()
            })
        }
    
    }
    
    class qCheckTVVC: UIViewController {
        
        let scrollView = UIScrollView()
        let myTabsView = MyCustomTabBarView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            let g = view.safeAreaLayoutGuide
            let contentG = scrollView.contentLayoutGuide
            let frameG = scrollView.frameLayoutGuide
            
            scrollView.translatesAutoresizingMaskIntoConstraints = false
            myTabsView.translatesAutoresizingMaskIntoConstraints = false
            
            view.addSubview(scrollView)
            scrollView.addSubview(myTabsView)
            
            NSLayoutConstraint.activate([
                scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                scrollView.heightAnchor.constraint(equalToConstant: 40.0),
                
                myTabsView.topAnchor.constraint(equalTo: contentG.topAnchor),
                myTabsView.leadingAnchor.constraint(equalTo: contentG.leadingAnchor),
                myTabsView.trailingAnchor.constraint(equalTo: contentG.trailingAnchor),
                myTabsView.bottomAnchor.constraint(equalTo: contentG.bottomAnchor),
                myTabsView.heightAnchor.constraint(equalTo: frameG.heightAnchor),
            ])
            
            scrollView.showsHorizontalScrollIndicator = false
        }
        
    }