当前位置: > > > Swift - 侧滑菜单的实现(样例2:仿QQ,菜单带缩放效果)

Swift - 侧滑菜单的实现(样例2:仿QQ,菜单带缩放效果)

(本文代码已升级至Swift4)

前面我写了一篇文章介绍如何实现侧滑菜单:Swift - 侧滑菜单的实现(样例1:主页向右滑动,露出下方菜单页)
其实现方式是,通过手势拖动主页面移动,从而露出下面的菜单页(其实后面的菜单页是固定不动的)。

下面演示另一种样式的实现(模仿手机QQ的侧滑菜单),主页面滑动停靠的过程中会逐渐缩小,同时菜单页也会逐渐移动放大,浮现出来。
(注:本文样例是基于前面文章的demo修改的,如果没阅读前文的话可以先去看下。为便于理解,下面将效果分两步实现。)

1,主页停靠侧边时尺寸逐渐缩小
(1)定义了新属性 minProportion,表示停靠时的缩小比例。在滑动时,再根据页面的位置实时计算出当前的缩放比例。
(2)在主页面与菜单页之间添加了个黑色遮罩层(blackCover), 初始化时是不透明的。随着菜单的展开透明度逐渐变为0。这样侧滑菜单有逐渐显示出来的效果。
      

ViewController.swift 代码如下(高亮处为修改过的地方):
import UIKit

class ViewController: UIViewController {
    // 主页导航控制器
    var mainNavigationController:UINavigationController!
    
    // 主页面控制器
    var mainViewController:MainViewController!
    
    // 菜单页控制器
    var menuViewController:MenuViewController?
    
    // 菜单页当前状态
    var currentState = MenuState.Collapsed {
        didSet {
            //菜单展开的时候,给主页面边缘添加阴影
            let shouldShowShadow = currentState != .Collapsed
            showShadowForMainViewController(shouldShowShadow: shouldShowShadow)
        }
    }
    
    // 菜单打开后主页在屏幕右侧露出部分的宽度
    let menuViewExpandedOffset: CGFloat = 60
    
    // 侧滑菜单黑色半透明遮罩层
    var blackCover: UIView?
    
    // 最小缩放比例
    let minProportion: CGFloat = 0.77
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        //初始化主视图
        mainNavigationController = UIStoryboard(name: "Main", bundle: nil)
            .instantiateViewController(withIdentifier: "mainNavigaiton")
            as! UINavigationController
        view.addSubview(mainNavigationController.view)
        
        //指定Navigation Bar左侧按钮的事件
        mainViewController = mainNavigationController.viewControllers.first
            as! MainViewController
        mainViewController.navigationItem.leftBarButtonItem?.action = #selector(showMenu)
        
        //添加拖动手势
        let panGestureRecognizer = UIPanGestureRecognizer(target: self,
                                                          action: #selector(handlePanGesture(_:)))
        mainNavigationController.view.addGestureRecognizer(panGestureRecognizer)
        
        //单击收起菜单手势
        let tapGestureRecognizer = UITapGestureRecognizer(target: self,
                                                          action: #selector(handleTapGesture))
        mainNavigationController.view.addGestureRecognizer(tapGestureRecognizer)
    }
    
    //导航栏左侧按钮事件响应
    @objc func showMenu() {
        //如果菜单是展开的则会收起,否则就展开
        if currentState == .Expanded {
            animateMainView(shouldExpand: false)
        }else {
            addMenuViewController()
            animateMainView(shouldExpand: true)
        }
    }
    
    //拖动手势响应
    @objc func handlePanGesture(_ recognizer: UIPanGestureRecognizer) {
        
        switch(recognizer.state) {
        // 刚刚开始滑动
        case .began:
            // 判断拖动方向
            let dragFromLeftToRight = (recognizer.velocity(in: view).x > 0)
            // 如果刚刚开始滑动的时候还处于主页面,从左向右滑动加入侧面菜单
            if (currentState == .Collapsed && dragFromLeftToRight) {
                currentState = .Expanding
                addMenuViewController()
            }
            
        // 如果是正在滑动,则偏移主视图的坐标实现跟随手指位置移动
        case .changed:
            let screenWidth = view.bounds.size.width
            var centerX = recognizer.view!.center.x +
                recognizer.translation(in: view).x
            //页面滑到最左侧的话就不许要继续往左移动
            if centerX < screenWidth/2 { centerX = screenWidth/2 }
            
            // 计算缩放比例
            var proportion:CGFloat = (centerX - screenWidth/2) /
                (view.bounds.size.width - menuViewExpandedOffset)
            proportion = 1 - (1 - minProportion) * proportion
            
            // 执行视差特效
            blackCover?.alpha = (proportion - minProportion) / (1 - minProportion)
            
            //主页面滑到最左侧的话就不许要继续往左移动
            recognizer.view!.center.x = centerX
            recognizer.setTranslation(.zero, in: view)
            //缩放主页面
            recognizer.view!.transform = CGAffineTransform.identity
                .scaledBy(x: proportion, y: proportion)
            
        // 如果滑动结束
        case .ended:
            //根据页面滑动是否过半,判断后面是自动展开还是收缩
            let hasMovedhanHalfway = recognizer.view!.center.x > view.bounds.size.width
            animateMainView(shouldExpand: hasMovedhanHalfway)
        default:
            break
        }
    }
    
    //单击手势响应
    @objc func handleTapGesture() {
        //如果菜单是展开的点击主页部分则会收起
        if currentState == .Expanded {
            animateMainView(shouldExpand: false)
        }
    }
    
    // 添加菜单页
    func addMenuViewController() {
        if (menuViewController == nil) {
            menuViewController = UIStoryboard(name: "Main", bundle: nil)
                .instantiateViewController(withIdentifier: "menuView")
                as? MenuViewController
            
            // 插入当前视图并置顶
            view.insertSubview(menuViewController!.view, at: 0)
            
            // 建立父子关系
            addChildViewController(menuViewController!)
            menuViewController!.didMove(toParentViewController: self)
            
            // 在侧滑菜单之上增加黑色遮罩层,目的是实现视差特效
            blackCover = UIView(frame: self.view.frame.offsetBy(dx: 0, dy: 0))
            blackCover!.backgroundColor = UIColor.black
            self.view.insertSubview(blackCover!,
                                    belowSubview: mainNavigationController.view)
        }
    }
    
    //主页自动展开、收起动画
    func animateMainView(shouldExpand: Bool) {
        // 如果是用来展开
        if (shouldExpand) {
            // 更新当前状态
            currentState = .Expanded
            // 动画
            let mainPosition = view.bounds.size.width * (1+minProportion/2)
                - menuViewExpandedOffset
            doTheAnimate(mainPosition: mainPosition, mainProportion: minProportion,
                         blackCoverAlpha: 0)
        }
            // 如果是用于隐藏
        else {
            // 动画
            doTheAnimate(mainPosition: view.bounds.size.width/2, mainProportion: 1,
                         blackCoverAlpha: 1) {
                finished in
                // 动画结束之后更新状态
                self.currentState = .Collapsed
                // 移除左侧视图
                self.menuViewController?.view.removeFromSuperview()
                // 释放内存
                self.menuViewController = nil;
                // 移除黑色遮罩层
                self.blackCover?.removeFromSuperview()
                // 释放内存
                self.blackCover = nil;
            }
        }
    }
    
    //主页移动动画、黑色遮罩层动画
    func doTheAnimate(mainPosition: CGFloat, mainProportion: CGFloat,
                      blackCoverAlpha: CGFloat, completion: ((Bool) -> Void)! = nil) {
        //usingSpringWithDamping:1.0表示没有弹簧震动动画
        UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1.0,
               initialSpringVelocity: 0, options: .curveEaseInOut, animations: {
                self.mainNavigationController.view.center.x = mainPosition
                self.blackCover?.alpha = blackCoverAlpha
                // 缩放主页面
                self.mainNavigationController.view.transform =
                    CGAffineTransform.identity.scaledBy(x: mainProportion, y: mainProportion)
        }, completion: completion)
    }
    
    //给主页面边缘添加、取消阴影
    func showShadowForMainViewController(shouldShowShadow: Bool) {
        if (shouldShowShadow) {
            mainNavigationController.view.layer.shadowOpacity = 0.8
        } else {
            mainNavigationController.view.layer.shadowOpacity = 0.0
        }
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
}

// 菜单状态枚举
enum MenuState {
    case Collapsed  // 未显示(收起)
    case Expanding   // 展开中
    case Expanded   // 展开
}
源码下载:hangge_1035.zip

2,侧滑展开时,菜单页逐渐放大
(1)菜单页的背景是透明的,背景图(大海背景)是添加在容器页(ViewController)里面的。
(2)菜单页的移动缩放原理和上面一样,也是根据滑动时,主页面的位置实时计算出菜单页的尺寸和位置。 
(3)由于侧滑菜单打开时是从屏幕外滑入的,新增属性 menuViewStartOffset 表示它的起始位置(超出屏幕多少距离)。
      

ViewController.swift 代码如下(高亮处为修改过的地方):
import UIKit

class ViewController: UIViewController {
    // 主页导航控制器
    var mainNavigationController:UINavigationController!
    
    // 主页面控制器
    var mainViewController:MainViewController!
    
    // 菜单页控制器
    var menuViewController:MenuViewController?
    
    // 菜单页当前状态
    var currentState = MenuState.Collapsed {
        didSet {
            //菜单展开的时候,给主页面边缘添加阴影
            let shouldShowShadow = currentState != .Collapsed
            showShadowForMainViewController(shouldShowShadow: shouldShowShadow)
        }
    }
    
    // 菜单打开后主页在屏幕右侧露出部分的宽度
    let menuViewExpandedOffset: CGFloat = 60
    
    // 侧滑开始时,菜单视图起始的偏移量
    let menuViewStartOffset: CGFloat = 70
    
    // 侧滑菜单黑色半透明遮罩层
    var blackCover: UIView?
    
    // 最小缩放比例
    let minProportion: CGFloat = 0.77
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        //状态栏文字改成白色
        UIApplication.shared.statusBarStyle = .lightContent;
        
        // 给根容器设置背景
        let imageView = UIImageView(image: UIImage(named: "back"))
        imageView.frame = UIScreen.main.bounds
        self.view.addSubview(imageView)
        
        //初始化主视图
        mainNavigationController = UIStoryboard(name: "Main", bundle: nil)
            .instantiateViewController(withIdentifier: "mainNavigaiton")
            as! UINavigationController
        view.addSubview(mainNavigationController.view)
        
        //指定Navigation Bar左侧按钮的事件
        mainViewController = mainNavigationController.viewControllers.first
            as! MainViewController
        mainViewController.navigationItem.leftBarButtonItem?.action = #selector(showMenu)
        
        //添加拖动手势
        let panGestureRecognizer = UIPanGestureRecognizer(target: self,
                                                          action: #selector(handlePanGesture(_:)))
        mainNavigationController.view.addGestureRecognizer(panGestureRecognizer)
        
        //单击收起菜单手势
        let tapGestureRecognizer = UITapGestureRecognizer(target: self,
                                                          action: #selector(handleTapGesture))
        mainNavigationController.view.addGestureRecognizer(tapGestureRecognizer)
    }
    
    //导航栏左侧按钮事件响应
    @objc func showMenu() {
        //如果菜单是展开的则会收起,否则就展开
        if currentState == .Expanded {
            animateMainView(shouldExpand: false)
        }else {
            addMenuViewController()
            animateMainView(shouldExpand: true)
        }
    }
    
    //拖动手势响应
    @objc func handlePanGesture(_ recognizer: UIPanGestureRecognizer) {
        
        switch(recognizer.state) {
        // 刚刚开始滑动
        case .began:
            // 判断拖动方向
            let dragFromLeftToRight = (recognizer.velocity(in: view).x > 0)
            // 如果刚刚开始滑动的时候还处于主页面,从左向右滑动加入侧面菜单
            if (currentState == .Collapsed && dragFromLeftToRight) {
                currentState = .Expanding
                addMenuViewController()
            }
            
        // 如果是正在滑动,则偏移主视图的坐标实现跟随手指位置移动
        case .changed:
            let screenWidth = view.bounds.size.width
            var centerX = recognizer.view!.center.x +
                recognizer.translation(in: view).x
            //页面滑到最左侧的话就不许要继续往左移动
            if centerX < screenWidth/2 { centerX = screenWidth/2 }
            
            // 计算缩放比例
            let percent:CGFloat = (centerX - screenWidth/2) /
                (view.bounds.size.width - menuViewExpandedOffset)
            var proportion:CGFloat = (centerX - screenWidth/2) /
                (view.bounds.size.width - menuViewExpandedOffset)
            proportion = 1 - (1 - minProportion) * proportion
            
            // 执行视差特效
            blackCover?.alpha = (proportion - minProportion) / (1 - minProportion)
            
            //主页面滑到最左侧的话就不许要继续往左移动
            recognizer.view!.center.x = centerX
            recognizer.setTranslation(.zero, in: view)
            //缩放主页面
            recognizer.view!.transform = CGAffineTransform.identity
                .scaledBy(x: proportion, y: proportion)
            
            //菜单视图移动
            menuViewController?.view.center.x = screenWidth/2 -
                menuViewStartOffset * (1 - percent)
            //菜单视图缩放
            let menuProportion = (1 + minProportion) - proportion
            menuViewController?.view.transform = CGAffineTransform.identity
                .scaledBy(x: menuProportion, y: menuProportion)
            
        // 如果滑动结束
        case .ended:
            //根据页面滑动是否过半,判断后面是自动展开还是收缩
            let hasMovedhanHalfway = recognizer.view!.center.x > view.bounds.size.width
            animateMainView(shouldExpand: hasMovedhanHalfway)
        default:
            break
        }
    }
    
    //单击手势响应
    @objc func handleTapGesture() {
        //如果菜单是展开的点击主页部分则会收起
        if currentState == .Expanded {
            animateMainView(shouldExpand: false)
        }
    }
    
    // 添加菜单页
    func addMenuViewController() {
        if (menuViewController == nil) {
            menuViewController = UIStoryboard(name: "Main", bundle: nil)
                .instantiateViewController(withIdentifier: "menuView")
                as? MenuViewController
            
            //菜单页先缩小
            menuViewController!.view.center.x = view.bounds.size.width/2
                * (1-(1-minProportion)/2) - menuViewStartOffset
            menuViewController!.view.transform = CGAffineTransform.identity
                .scaledBy(x: minProportion, y: minProportion)
            
            // 插入当前视图
            view.insertSubview(menuViewController!.view,
                               belowSubview: mainNavigationController.view)
            
            // 建立父子关系
            addChildViewController(menuViewController!)
            menuViewController!.didMove(toParentViewController: self)
            
            // 在侧滑菜单之上增加黑色遮罩层,目的是实现视差特效
            blackCover = UIView(frame: self.view.frame.offsetBy(dx: 0, dy: 0))
            blackCover!.backgroundColor = UIColor.black
            self.view.insertSubview(blackCover!,
                                    belowSubview: mainNavigationController.view)
        }
    }
    
    //主页自动展开、收起动画
    func animateMainView(shouldExpand: Bool) {
        // 如果是用来展开
        if (shouldExpand) {
            // 更新当前状态
            currentState = .Expanded
            // 动画
            let mainPosition = view.bounds.size.width * (1+minProportion/2)
                - menuViewExpandedOffset
            doTheAnimate(mainPosition: mainPosition, mainProportion: minProportion,
                         menuPosition: view.bounds.size.width/2, menuProportion: 1,
                         blackCoverAlpha: 0)
        }
            // 如果是用于隐藏
        else {
            // 动画
            let menuPosition = view.bounds.size.width/2 * (1-(1-minProportion)/2)
                - menuViewStartOffset
            doTheAnimate(mainPosition: view.bounds.size.width/2, mainProportion: 1,
                         menuPosition: menuPosition, menuProportion: minProportion,
                         blackCoverAlpha: 1) {
                finished in
                // 动画结束之后更新状态
                self.currentState = .Collapsed
                // 移除左侧视图
                self.menuViewController?.view.removeFromSuperview()
                // 释放内存
                self.menuViewController = nil;
                // 移除黑色遮罩层
                self.blackCover?.removeFromSuperview()
                // 释放内存
                self.blackCover = nil;
            }
        }
    }
    
    //主页移动动画、黑色遮罩层动画
    func doTheAnimate(mainPosition: CGFloat, mainProportion: CGFloat,
                      menuPosition: CGFloat, menuProportion: CGFloat,
                      blackCoverAlpha: CGFloat, completion: ((Bool) -> Void)! = nil) {
        //usingSpringWithDamping:1.0表示没有弹簧震动动画
        UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1.0,
               initialSpringVelocity: 0, options: .curveEaseInOut, animations: {
                self.mainNavigationController.view.center.x = mainPosition
                self.blackCover?.alpha = blackCoverAlpha
                // 缩放主页面
                self.mainNavigationController.view.transform =
                    CGAffineTransform.identity.scaledBy(x: mainProportion, y: mainProportion)
                
                // 菜单页移动
                self.menuViewController?.view.center.x = menuPosition
                // 菜单页缩放
                self.menuViewController?.view.transform =
                    CGAffineTransform.identity.scaledBy(x: menuProportion, y: menuProportion)
        }, completion: completion)
    }
    
    //给主页面边缘添加、取消阴影
    func showShadowForMainViewController(shouldShowShadow: Bool) {
        if (shouldShowShadow) {
            mainNavigationController.view.layer.shadowOpacity = 0.8
        } else {
            mainNavigationController.view.layer.shadowOpacity = 0.0
        }
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
}

// 菜单状态枚举
enum MenuState {
    case Collapsed  // 未显示(收起)
    case Expanding   // 展开中
    case Expanded   // 展开
}
源码下载:hangge_1035.zip
评论6
  • 6楼
    2017-11-21 17:37
    啊皓

    站长 ,能给一份swift4.0的版本吗?我自己改完以后发现滑动就会报错

    站长回复

    文章代码现已更新,你可以再看下。

  • 5楼
    2017-03-30 13:22
    wubowen

    站长,我MainViewController放了个按钮push到下一个界面,点击导航栏返回按钮回来不到了

    站长回复

    只要自定义一下返回按钮,并添加相关的返回事件即可。具体参考我之前的文章:Swift - 修改导航栏“返回”按钮文字,图标

  • 4楼
    2016-07-29 18:04
    linjoe

    站长,我做个登录界面,登录完之后跳转到这个有侧栏的界面,但是传值的时候出现:Presenting view controllers on detached view controllers is discouraged 。这怎么回事

    站长回复

    提供的信息太少,不太确定是什么问题。

  • 3楼
    2016-06-30 17:54
    风之沙

    站长,我在MainViewController里面放了一个按钮,push到下一个视图控制器(OneViewController),在OneViewController这个视图控制器里面我还是可以进行侧滑,我想让它在OneViewController这个视图控制器里面不能侧滑该怎么办?

    站长回复

    你可以在拖动手势响应中加个判断,如果不是根视图则不执行侧滑。 


        //拖动手势响应
        func handlePanGesture(recognizer: UIPanGestureRecognizer) {
            
            if mainNavigationController.visibleViewController != mainViewController {
                return
            }

    .........

  • 2楼
    2016-03-23 16:54
    AllocAndInit

    当我用代码实现了相应的效果后,发现可能是因为父子关系的问题,出现了一些莫名其妙的问题,比如连TableView 都不能点击了,你有什么方法吗?

    站长回复

    不知道你的代码是什么样的,所以这个只能你自己找找看,我也帮不上什么忙了。

  • 1楼
    2016-01-30 20:37
    wIng_Z

    关注站长 学到了太多的东西

    站长回复

    欢迎常来看看