实现 iOS PageMenu 滑动或者点击切换 ViewController 内容

本文会实现一个轻量级的 PageMenu, 用户可以点击按钮或者滑动视图切换各个 ViewController,点击的按钮会放在 titleView 内。导入到项目的时候只需要添加一个类就OK了。

ScrollView

我们知道 ScrollView 有一个属性 contentSize,它表示的是 ScrollView 的内容可滚动区域,在实现的时候,如果滚动视图有 n 个, 则可设置:

scrollView.contentSize = CGSize(width: scrollView.bounds.width * n, height: scrollView.bounds.height)

由于切换各个视图的时候需要一个分页的动画效果,所以需要设置 ScrollView

 scrollView.isPagingEnabled = true

此时,假如都设置好了的话,就可以在各个视图来回滑动切换了,就是这么简单!

点击切换按钮

点击切换按钮的时候,ScrollView 需要滚动到相对应的视图,此时需要修改的是 ScrollView 的 contentOffset 属性:

scrollView.setContentOffset(viewControllersFrame[index].origin, animated: true)

提示动画条

当用户滑动到第 n 个界面的时候,此时提示条应该移动到第 n 个界面对应的点击按钮

此时需要在 滑动视图点击按钮 时做相应的处理

  • 滑动视图时的处理:

在 ScrollView 的代理方法内,根据对应按钮的 frame 比例,执行滚动动画

func scrollViewDidScroll(_ scrollView: UIScrollView) {
   UIView.animate(withDuration: moveDuration, animations: {
       let x = scrollView.contentOffset.x * self.scale + self.itemsOriginX[0]
       self.indicatorView.frame.origin.x = x
     })
}
  • 点击按钮时的处理

在点击按钮添加的方法内,添加滚动动画:

UIView.animate(withDuration: moveDuration, animations: {
    self.indicatorView.frame.origin.x = self.itemsOriginX[index]
 })

我的实现类:

最终实现的滚动容器类代码如下:

import UIKit

class MenuContainerViewController: UIViewController {
    
    var menus: [UIButton] = [UIButton]()
    var viewControllers: [UIViewController] = [UIViewController]()

    var itemColor = UIColor.black
    var indicatorColor = UIColor.blue {
        didSet {
            indicatorView?.backgroundColor = indicatorColor
        }
    }
    
    // designated init view controller
    init(menus: [UIButton], viewControllers: [UIViewController]) {
        super.init(nibName: nil, bundle: nil)
        
        if menus.count != viewControllers.count {
            fatalError("menus.count != viewControllers.count")
        } 
        
        self.menus = menus
        self.viewControllers = viewControllers
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: - Private properties
    private var scrollView: UIScrollView!

    private var itemsTitle: [String] = [String]()
    private var viewControllersFrame: [CGRect] = [CGRect]()
    fileprivate var itemsOriginX: [CGFloat] = [CGFloat]()
    
    fileprivate var indicatorView: UIView!
    
    fileprivate var indicatorViewLastOriginX: CGFloat = 0.0 {
        didSet {
            indicatorCopyView?.frame.origin.x = indicatorViewLastOriginX
        }
    }
    
    fileprivate let indicatorViewWidth: CGFloat = 30
    
    fileprivate var scale: CGFloat!
    
    fileprivate let moveDuration: TimeInterval = 0.2
    
    // Due to 'sectionIndicatorView' will reset frame when viewDidDisappear did called,
    // so, add 'indicatorCopyView' as the copy view
    fileprivate var indicatorCopyView: UIView!
    fileprivate var shouldAdjustCopyIndicatorView = false
    
    // MARK: - View controller lifecycle
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        automaticallyAdjustsScrollViewInsets = false
        self.navigationController?.navigationBar.isTranslucent = false
        
        scrollView = UIScrollView()
        let customTitleView = UIView()
        let titleStackView = UIStackView()
        indicatorView = UIView()
        indicatorCopyView = UIView()
        
        scrollView.delegate = self
        scrollView.isPagingEnabled = true
        scrollView.showsVerticalScrollIndicator = false
        scrollView.showsHorizontalScrollIndicator = false
        
        for button in menus {
            button.setTitleColor(itemColor, for: .normal)
            itemsTitle.append(button.currentTitle!)
        }
        
        customTitleView.backgroundColor = UIColor.white
        for item in menus {
            titleStackView.addArrangedSubview(item)
        }
        titleStackView.alignment = .center
        titleStackView.axis = .horizontal
        titleStackView.distribution = .fillEqually
        
        for i in 0 ..< viewControllers.count {
            let subvc = viewControllers[i]
            self.addChildViewController(subvc)
            scrollView.addSubview(subvc.view)
            subvc.didMove(toParentViewController: self)
        }
        
        let titleViewWidth: CGFloat = 200
        let titleViewHeight: CGFloat = 44
        let stackViewHeight: CGFloat = 40
        
        let titleViewFrame = CGRect(x: 0, y: 0, width: titleViewWidth, height: titleViewHeight)
        let stackViewFrame = CGRect(x: 0, y: 0, width: titleViewWidth, height: stackViewHeight)
        let indicatorViewFrame = CGRect(x: 0, y: titleViewHeight - 2, width: indicatorViewWidth, height: 1)
        
        customTitleView.frame = titleViewFrame
        customTitleView.frame.origin.x = self.view.frame.midX - titleViewWidth/2
        
        titleStackView.frame = stackViewFrame
        
        indicatorView.frame = indicatorViewFrame
        indicatorView.backgroundColor = indicatorColor
        
        // for menuItems originX
        var itemOriginX: CGFloat = 0
        let itemWidth: CGFloat = titleViewWidth/3
        for item in menus {
            item.addTarget(self, action: #selector(contentOffSetXForButton(sender:)), for: .touchUpInside)
            let itemFrame = CGRect(x: itemOriginX, y: 0, width: itemWidth, height: stackViewHeight)
            item.frame = itemFrame
            let indicatorOriginX = itemFrame.midX - indicatorViewWidth/2
            itemsOriginX.append(indicatorOriginX)
            itemOriginX += itemWidth
        }
        
        // for sectionIndicatorView
        indicatorView.frame.origin.x = itemsOriginX[0]
        indicatorViewLastOriginX = indicatorView.frame.origin.x
        
        // indicator copy view
        indicatorCopyView.frame = indicatorView.frame
        indicatorCopyView.backgroundColor = indicatorView.backgroundColor
        indicatorCopyView.isHidden = true
        
        // indicator scroll scale
        let indicatorScale = itemsOriginX[1] - itemsOriginX[0]
        scale = indicatorScale / UIScreen.main.bounds.size.width
        
        customTitleView.addSubview(titleStackView)
        customTitleView.addSubview(indicatorView)
        customTitleView.addSubview(indicatorCopyView)
        
        self.parent?.navigationItem.titleView = customTitleView
        
        view.addSubview(scrollView)
    }
    
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        var scrollViewFrame = view.frame
        scrollViewFrame.size.height -= 49
        
        scrollView.frame = view.frame
        
        let width = scrollViewFrame.width
        let height = scrollViewFrame.height
        
        scrollView.contentSize = CGSize(width: width * 3, height: height)
        
        // has [viewControllersFrame]
        var vcOriginX: CGFloat = 0
        for _ in 0 ..< viewControllers.count {
            viewControllersFrame.append(CGRect(x: vcOriginX, y: 0, width: width, height: height))
            vcOriginX += width
        }
        
        for i in 0 ..< viewControllers.count {
            viewControllers[i].view.frame = viewControllersFrame[i]
        }
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        if shouldAdjustCopyIndicatorView {
            UIView.animate(withDuration: 0.0, animations: {
                self.indicatorView?.frame.origin.x = self.indicatorViewLastOriginX
            }) { (_) in
                self.indicatorCopyView?.isHidden = true
                self.indicatorView?.isHidden = false
                
                self.shouldAdjustCopyIndicatorView = false
            }
        }
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        indicatorCopyView.isHidden = false
        indicatorView.isHidden = true
        shouldAdjustCopyIndicatorView = true        
    }
    
    // MARK: - Helper
    @objc private func contentOffSetXForButton(sender: UIButton){
        let currentTitle = sender.currentTitle!
        let index = itemsTitle.index(of: currentTitle)!
        
        scrollView.setContentOffset(viewControllersFrame[index].origin, animated: true)
        UIView.animate(withDuration: moveDuration, animations: {
            self.indicatorView.frame.origin.x = self.itemsOriginX[index]
            self.indicatorViewLastOriginX = self.indicatorView.frame.origin.x
        })
    }
}

// MRAK: - Scroll view delegate

extension MenuContainerViewController: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        UIView.animate(withDuration: moveDuration, animations: {
            let x = scrollView.contentOffset.x * self.scale + self.itemsOriginX[0]
            self.indicatorView.frame.origin.x = x
            self.indicatorViewLastOriginX = x
        })
    }
}

调用的测试类:

import UIKit

class ViewController: UIViewController {
    
    // *********************************************
    //  Add MenuContainerViewController like this
    private var addedPageViewController = false
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        if !addedPageViewController {
            addedPageViewController = true
            
            let scrollContainerVC = MenuContainerViewController(menus: pageItems(),
                                                                viewControllers: pageViewContorllers())
            self.addChildViewController(scrollContainerVC)
            scrollContainerVC.view.frame = view.bounds
            view.addSubview(scrollContainerVC.view)
            scrollContainerVC.didMove(toParentViewController: self)
        }
    }
    // *********************************************
    
    // MRAK: Test data
    private func pageItems() -> [UIButton] {        
        let red = UIButton()
        let gray = UIButton()
        let purple = UIButton()
        
        red.setTitle("red", for: .normal)
        gray.setTitle("gray", for: .normal)
        purple.setTitle("purple", for: .normal)
        
        var items = [UIButton]()

        items.append(red)
        items.append(gray)
        items.append(purple)

        return items
    }
    
    private func pageViewContorllers() -> [UIViewController] {        
        let storyboard = UIStoryboard(name: "Main", bundle: nil)

        let firstViewController = storyboard.instantiateViewController(withIdentifier: "FirstViewController") as!
        FirstViewController
        let secondViewController = storyboard.instantiateViewController(withIdentifier: "SecondViewController") as! SecondViewController
        let thirdViewController = storyboard.instantiateViewController(withIdentifier: "ThirdViewController") as!
        ThirdViewController
        
        var vcs = [UIViewController]()

        vcs.append(firstViewController)
        vcs.append(secondViewController)
        vcs.append(thirdViewController)

        return vcs
    }

}

Demo地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,800评论 25 707
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,066评论 4 62
  • wget -c -O master.zip --no-check-certificate https://gith...
    KK的记录阅读 548评论 0 1
  • 晴备雨伞,饱带饥粮,现在是时候备份保险了! 我刚进入保险行业的时候,意志还不是很坚定。被拒绝得多了,我有时候甚至会...
    黄晓宁阅读 449评论 0 0
  • “你好,你挺漂亮的,不过我不是贪脸蛋儿的男人,我直接说了吧。我没车没钱没房,没上进心,没责任心,花心是有的,不过我...
    段童阅读 514评论 1 2