Swift Recreating the iPhone App Switcher Page with a Scrollview

Viewed 185

I am trying to recreate the iPhone App Switcher page - the one that comes up when you swipe up.

I'm building it by adding an array of views representing apps to a scroll view.

Unfortunately, I am getting caught up setting the spacing between the views. I am trying to set it using a parabolic function so the views collapse to the left hand side. I think the equation might be incorrect.

Here's my code for scrollViewDidScroll:

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    items.enumerated().forEach { (index, tabView) in
        guard let tabSuperView = tabView.superview else {return}
        let screenWidth = UIScreen.main.bounds.width
        
        // Return value between 0 and 1 depending on the location of the tab within the visible screen
        // 0 Left hand side or offscreen
        // 1 Right hand side or offscreen
        let distanceMoved = tabSuperView.convert(CGPoint(x: tabView.frame.minX, y: 0), to: view).x
        let screenOffsetPercentage: CGFloat = distanceMoved / screenWidth
        
        // Scale
        let minValue: CGFloat = 0.6
        let maxValue: CGFloat = 1
        let scaleAmount = minValue + (maxValue - minValue) * screenOffsetPercentage
        let scaleSize = CGAffineTransform(scaleX: scaleAmount, y: scaleAmount)
        tabView.transform = scaleSize
        
        // Set a max and min
        let percentAcrossScreen = max(min(distanceMoved / screenWidth, 1.0), 0)
        
        // Spacing
        if let prevTabView = items.itemAt(index - 1) {
            // Rest of tabs
            let constant: CGFloat = 100
            let xFrame = prevTabView.frame.origin.x + (pow(percentAcrossScreen, 2) * constant)
            tabView.frame.origin.x = max(xFrame, 0)
        } else {
            // First tab
            tabView.frame.origin.x = 20
        }
    }
}

How would you fix this to replicate the scrolling experience of the iPhone app switcher page?

enter image description here

Sample Project: https://github.com/Alexander-Frost/ViewContentOffset

1 Answers

The general idea (I'll call the moving views "cards")...

As you "push" a card to the right, calculate the percentage of the distance from the leading edge of the container to the leading edge of the card, based on a portion of the container width. Then, position the leading edge of the next card that percentage of the width of the card.

So, if the cards are 70% of the width of the view, we want the top card to be almost pushed off to the right when the "dragging" card is 1/3rd of the distance from the leading edge of the view.

If the dragging card is one-half of 1/3rd, we want the next card's leading to be 1/2 the width of the card.

As I said in one of your earlier questions, I'm not sure using a scroll view will be of benefit, since you'll be changing the relative distances as you drag.

Here's an example:

enter image description here

You can try out this code - just create a new project and replace the default view controller class with:

class ViewController: UIViewController {

    let switcherView = SwitcherView()
    
    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .systemYellow
        
        switcherView.translatesAutoresizingMaskIntoConstraints = false
        switcherView.backgroundColor = .white
        
        view.addSubview(switcherView)
        
        // respect safe area
        let g = view.safeAreaLayoutGuide
        
        NSLayoutConstraint.activate([
            // constrain switcher view to all 4 sides of safe area
            switcherView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
            switcherView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
            switcherView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
            switcherView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
        ])
        
    }
    
}

Here's the "card" view class:

class CardView: UIView {
    
    var theLabels: [UILabel] = []
    
    var cardID: Int = 0 {
        didSet {
            theLabels.forEach {
                $0.text = "\(cardID)"
            }
        }
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        
        for i in 1...5 {
            let v = UILabel()
            v.font = .systemFont(ofSize: 24.0)
            v.translatesAutoresizingMaskIntoConstraints = false
            addSubview(v)
            switch i {
            case 1:
                v.topAnchor.constraint(equalTo: topAnchor, constant: 10.0).isActive = true
                v.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16.0).isActive = true
            case 2:
                v.topAnchor.constraint(equalTo: topAnchor, constant: 10.0).isActive = true
                v.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16.0).isActive = true
            case 3:
                v.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10.0).isActive = true
                v.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16.0).isActive = true
            case 4:
                v.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10.0).isActive = true
                v.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16.0).isActive = true
            default:
                v.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
                v.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
            }
            theLabels.append(v)
        }
        
        layer.cornerRadius = 6
        
        // border
        layer.borderWidth = 1.0
        layer.borderColor = UIColor.gray.cgColor
        
        // shadow
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowOffset = CGSize(width: -3, height: 3)
        layer.shadowOpacity = 0.25
        layer.shadowRadius = 2.0
    }
    
}

and here's the "SwitcherView" class - where all the action takes place:

class SwitcherView: UIView {

    var cards: [CardView] = []
    
    var currentCard: CardView?
    
    var firstLayout: Bool = true

    // useful during development...
    //  if true, highlight the current "control" card in yellow
    //  if false, leave them all cyan
    let showHighlight: Bool = false
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        
        clipsToBounds = true
        
        // add 20 "cards" to the view
        for i in 1...20 {
            let v = CardView()
            v.backgroundColor = .cyan
            v.cardID = i
            cards.append(v)
            addSubview(v)
            v.isHidden = true
        }
        
        // add a pan gesture recognizer to the view
        let pan = UIPanGestureRecognizer(target: self, action: #selector(self.didPan(_:)))
        addGestureRecognizer(pan)
        
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        
        if firstLayout {
            // if it's the first time through, layout the cards
            firstLayout = false
            if let firstCard = cards.first {
                if firstCard.frame.width == 0 {
                    cards.forEach { thisCard in
                        //thisCard.alpha = 0.750
                        thisCard.frame = CGRect(origin: .zero, size: CGSize(width: self.bounds.width, height: self.bounds.height))
                        thisCard.transform = CGAffineTransform(scaleX: 0.71, y: 0.71)
                        thisCard.frame.origin.x = 0
                        if thisCard == cards.last {
                            thisCard.frame.origin.x = 10
                        }
                        thisCard.isHidden = false
                    }
                    doCentering(for: cards.last!)
                }
            }
        }
    }
    
    @objc func didPan(_ gesture: UIPanGestureRecognizer) -> Void {
        
        let translation = gesture.translation(in: self)
        
        var pt = gesture.location(in: self)
        pt.y = self.bounds.midY
        for c in cards.reversed() {
            if c.frame.contains(pt) {
                if let cc = currentCard {
                    if  let idx1 = cards.firstIndex(of: cc),
                        let idx2 = cards.firstIndex(of: c),
                        idx2 > idx1 {
                        if showHighlight {
                            currentCard?.backgroundColor = .cyan
                        }
                        currentCard = c
                        if showHighlight {
                            currentCard?.backgroundColor = .yellow
                        }
                    }
                } else {
                    currentCard = c
                    if showHighlight {
                        currentCard?.backgroundColor = .yellow
                    }
                }
                break
            }
        }
        
        switch gesture.state {
        case .changed:
            if let controlCard = currentCard {
                // update card leading edge
                controlCard.frame.origin.x += translation.x
                // don't allow drag left past 1.0
                controlCard.frame.origin.x = max(controlCard.frame.origin.x, 1.0)
                // update the positions for the rest of the cards
                updateCards(controlCard)
                gesture.setTranslation(.zero, in: self)
            }
            
        case .ended:
            if showHighlight {
                currentCard?.backgroundColor = .cyan
            }
            
            guard let controlCard = currentCard else {
                return
            }
            
            if let idx = cards.firstIndex(of: controlCard) {
                // use pan velocity to "throw" the cards
                let velocity = gesture.velocity(in: self)
                // convert to a reasonable Int value
                let offset: Int = Int(floor(velocity.x / 500.0))
                // step up or down in array of cards based on velocity
                let newIDX = max(min(idx - offset, cards.count - 1), 0)
                doCentering(for: cards[newIDX])
            }

            currentCard = nil
            
        default:
            break
        }
        
    }
    
    func updateCards(_ controlCard: CardView) -> Void {
        
        guard let idx = cards.firstIndex(of: controlCard) else {
            print("controlCard not found in array of cards - can't update")
            return
        }
        
        var relativeCard: CardView = controlCard
        var n = idx
        
        // for each card to the right of the control card
        while n < cards.count - 1 {
            let nextCard = cards[n + 1]
            // get percent distance of leading edge of relative card
            //  to 33% of the view width
            let pct = relativeCard.frame.origin.x / (self.bounds.width * 1.0 / 3.0)
            // move next card that percentage of the width of a card
            nextCard.frame.origin.x = relativeCard.frame.origin.x + (relativeCard.frame.size.width * pct) // min(pct, 1.0))
            relativeCard = nextCard
            n += 1
        }
        
        // reset relative card and index
        relativeCard = controlCard
        n = idx
        
        // for each card to the left of the control card
        while n > 0 {
            let prevCard = cards[n - 1]
            // get percent distance of leading edge of relative card
            //  to half the view width
            let pct = relativeCard.frame.origin.x / self.bounds.width
            // move prev card that percentage of 33% of the view width
            prevCard.frame.origin.x = (self.bounds.width * 1.0 / 3.0) * pct
            relativeCard = prevCard
            n -= 1
        }
        
        self.cards.forEach { c in
            
            let x = c.frame.origin.x
            
            // scale transform each card between 71% and 75%
            //  based on card's leading edge distance to one-half the view width
            let pct = x / (self.bounds.width * 0.5)
            let sc = 0.71 + (0.04 * min(pct, 1.0))
            c.transform = CGAffineTransform(scaleX: sc, y: sc)
            
            // set translucent for far left cards
            if cards.count > 1 {
                c.alpha = min(1.0, x / 10.0)
            }
            
        }
        
    }
    
    func doCentering(for cCard: CardView) -> Void {
        
        guard let idx = cards.firstIndex(of: cCard) else {
            return
        }
        
        var controlCard = cCard
        
        // if the leading edge is greater than 1/2 the view width,
        //  and it's not the Bottom card,
        //  set cur card to the previous card
        if idx > 0 && controlCard.frame.origin.x > self.bounds.width * 0.5 {
            controlCard = cards[idx - 1]
        }
        
        // center of control card will be offset to the right of center
        var newX = self.bounds.width * 0.6
        if controlCard == cards.last {
            // if it's the Top card, center it
            newX = self.bounds.width * 0.5
        }
        if controlCard == cards.first {
            // if it's the Bottom card, center it + just a little to the right
            newX = self.bounds.width * 0.51
        }
        UIView.animate(withDuration: 0.5, delay: 0.0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.1, options: [.allowUserInteraction, .curveEaseOut], animations: {
            controlCard.center.x = newX
            self.updateCards(controlCard)
        }, completion: nil)
        
    }
    
}

When we stop panning, the code finds the card with the Leading edge closest to the center of the view... If its leading edge is less-than 50% of the width, it slides it back to the left so it becomes the "center" card. If its leading edge is greater-than 50% of the width, it slides to the right and the previous card becomes the "center" card.


Edit - added some "Pan Velocity" handling to the SwitcherView.

Related