【问题标题】:Swift Recreating the iPhone App Switcher Page with a ScrollviewSwift 使用 Scrollview 重新创建 iPhone App Switcher 页面
【发布时间】:2021-02-06 23:55:38
【问题描述】:

我正在尝试重新创建 iPhone App Switcher 页面 - 向上滑动时出现的页面。

我通过将代表应用程序的视图数组添加到滚动视图来构建它。

不幸的是,我正忙于设置视图之间的间距。我正在尝试使用抛物线函数设置它,以便视图折叠到左侧。我认为方程式可能不正确。

这是我的 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
        }
    }
}

您将如何解决此问题以复制 iPhone 应用切换器页面的滚动体验?

示例项目: https://github.com/Alexander-Frost/ViewContentOffset

【问题讨论】:

    标签: swift uiscrollview


    【解决方案1】:

    总体思路(我将移动视图称为“卡片”)...

    当您将卡片“推”到右侧时,根据容器宽度的一部分计算从容器前缘到卡片前缘的距离百分比。然后,按照卡片宽度的百分比定位下一张卡片的前缘。

    所以,如果卡片是视图宽度的 70%,我们希望当“拖动”卡片距视图前缘距离的 1/3 时,顶部卡片几乎被推到右侧查看。

    如果拖动的卡片是 1/3 的二分之一,我们希望下一张卡片的前导是卡片宽度的 1/2。

    正如我在您之前的一个问题中所说,我不确定使用滚动视图是否有好处,因为您将在拖动时更改相对距离。

    这是一个例子:

    您可以试用此代码 - 只需创建一个新项目并将默认视图控制器类替换为:

    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),
            ])
            
        }
        
    }
    

    这是“卡片”视图类:

    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
        }
        
    }
    

    这里是“SwitcherView”类——所有动作都发生在这里:

    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)
            
        }
        
    }
    

    当我们停止平移时,代码会找到前缘最靠近视图中心的卡片...如果它的前缘小于宽度的 50%,它会将其滑回左侧,以便成为“中心”牌。如果它的前缘大于宽度的 50%,它会向右滑动,前一张卡片成为“中心”卡片。


    编辑 - 为SwitcherView 添加了一些“平移速度”处理。

    【讨论】:

    • 基于 iPhone 与屏幕的交互,看起来 Apple 使用的是滚动视图而不是平移手势。知道如何使用滚动视图来处理这个问题吗?
    • @Ryan - 仅仅因为 UI 元素 似乎 在滚动视图中并不一定意味着它 在滚动视图中。我使用更新的SwitcherView 类编辑了我的答案,因此它使用 Pan Gesture Velocity 来允许“抛出”卡片。看看吧。
    • @Ryan - 给你一个问题...你认为使用UIScrollView 作为基础会有什么好处?您仍然需要在 didScroll 上进行类似的帧计算,并且您需要找到一种方法来设置 .contentSize.width 以允许在滚动时动态更改结构。
    • 您可以通过滚动视图访问所有委托方法。将它们全部构建出来非常复杂。您也无法使用平移手势进行小交互。在 iPhone 上按住切换器视图的同时向上滑动,所有视图都会变小,这是滚动视图所独有的
    • @Ryan - 你玩过我发布的示例代码吗?我认为你没有抓住重点——或者可能是几点。 1) 如果您仔细检查应用程序切换器 UI,在触摸移动一小段距离之前没有任何变化,这表示平移手势,而不是滚动视图。 2) 视图独立移动和调整大小 - 与 viewForZooming 发生的情况相反 - 同样,不像滚动视图。 3) 与基本滚动相比,还有 LOT 更多内容。 4) Stack Overflow 上到处都是自愿提供帮助的人。没有人关心“答案的分数”。
    猜你喜欢
    • 1970-01-01
    • 2022-12-12
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2014-01-24
    • 2014-08-05
    • 1970-01-01
    相关资源
    最近更新 更多