使用[栈]结构完成一个页面切换控制器 Page Stack

作者: ZeroJian

在很多新闻咨询类 App 中, 经常会使用一个滚动视图来展示各种分类信息, 使用 ScrollView 或 CollectionView 添加多个 View, 他们都在一个视图控制器中, 通过滚动达到切换页面的目的, 这种视图的结构和布局大概是这样:

toutiaoCollectionView

通过在视图控制器添加一层滚动视图扩充它的 contentSize 达到切换内容的目的

这种视图结构在处理单层页面时很方便, 但是如果某些场景需要处理多层视图结构如何处理呢, 例如很多打车软件, 它们都有一个主视图控制器,底层显示着地图,根据业务功能的不同显示不同的视图元素,点击打车或一些按钮会跳转到第二层视图, 但是它们的视图控制器都还是在主视图控制器中

DiDIStackPage

这种视图结构很适合使用 栈 来实现, 我们回顾一下系统 UINavigationController 的实现方式, UINavigationController通过 Navigation Stack 来管理 View controller,对View进行push/pop:

Page Stack的实现

我们参照系统的 UINavigationController 实现一个控制 View 的导航控制器, Page Stack

首先创建一个类, 定义一些基础属性

public enum StackViewNavigationOperation: Int {
    case none
    case push
    case pop
}

class StackViewNavigation {
    
    var views: [UIView] = []
    
    var isAnimation: Bool = false
    
    var topView: UIView
    
    var rootView: UIView
    
    // 用来临时保存上一个 topView
    var lastTopView: UIView
    
    /// UIViewController 的 View, 所有视图都添加到此视图上
    var superView: UIView
    
    init(viewController:UIViewController, rootView: UIView) {
        self.superView = viewController.view
        self.superViewHeight = viewController.view.bounds.height
        self.rootView = rootView
        self.topView = rootView
        self.lastTopView = rootView
        views.append(rootView)
    }
}

考虑到扩展性, 我们可以定义一个视图切换动画协议来处理视图的切换

class StackViewNavigation: ViewSwitchAnimation {
    ...
}

protocol ViewSwitchAnimation {
    var superView: UIView { get set }
    var superViewHeight: CGFloat { get set }
    /// 记录动画中的 closure, 便于外部监听动画中事件
    var nextViewAnimationAction: ((UIView, Bool) -> Void)? { get set }
    
    /// 设置切换的视图布局, 便于更改视图高度
    var bottomInster: CGFloat { get set }
    var topInster: CGFloat { get set }
}

extension ViewSwitchAnimation {
    /// 视图切换动画
    func animation(operation: StackViewNavigationOperation, topView: UIView, nextView: UIView, animated: Bool,completion: ((Bool) -> Void)?) {
        ....
        /// 动画结束 closure
        completion?(finished)
    }
}

我们实现视图的一些栈操作, 类似系统的 UINavigationController

func pushView(_ view: UIView, animated: Bool) -> Bool {
        let success = viewAnimation(operation: .push, nextView: view, animated: animated) { (finished) in
            if finished {
                self.views.append(view)
            }
        }
        return success
}


func popView(animated: Bool) -> UIView? {
        
        guard views.count - 2 >= 0 else {
            print("popView failure, 已经是 rootView")
            return nil
        }
        
        let nextView = views[views.count - 2]
        
        let success = viewAnimation(operation: .pop, nextView: nextView, animated: animated) { (finished) in
            self.views.removeLast()
        }
        
        return success ? topView : nil
}


func popToView(_ view: UIView, animated: Bool) -> [UIView]? {
        
        guard let index = views.index(of: view) else {
            print("PopToView failure, 无法找到当前 view")
            return nil
        }
        
        guard views.last != view else {
            print("PopToView failure, 和当前 topView 是同一个 view")
            return nil
        }
        
        let viewsSlice = views[0...index]
        let popedSlice = views[(index + 1)...]
        
        let success = viewAnimation(operation: .pop, nextView: view, animated: animated) { (finished) in
            if finished {
                self.views = Array(viewsSlice)
            }
        }
        
        return success ? Array(popedSlice) : nil
}


func popToRootView(animated: Bool) -> [UIView]? {
        
        guard rootView != topView else {
            print("popToRootView failure, 和当前 topView 是同一个 view")
            return nil
        }
        
        var popedView = views
        popedView.removeFirst()
        
        let success = viewAnimation(operation: .pop, nextView: rootView, animated: animated) { (finished) in
                        if finished {
                            self.views = [self.rootView]
                        }
                    }
        
        return success ? popedView : nil
}

/// 弹出视图到一个新的 stack root view
    ///
    /// - Parameters:
    ///   - view: view
    ///   - animated: 动画
    /// - Returns: 返回上一个 rootView 和 弹出的 views
func popToNewRootView(view: UIView, animated: Bool) -> (UIView?, [UIView]?) {
        
        guard view != topView else {
            print("popToNewRootView failure, 和当前 topView 是同一个 view")
            return (nil, nil)
        }
        
        guard !isAnimation else {
            print("popToNewRootView failure, 前一个动画还未结束")
            return (nil, nil)
        }
        
        rootView = view
        let lastRootView = views.removeFirst()
        views.insert(view, at: 0)
        
        return (lastRootView, popToRootView(animated: animated))
}

我们完成了基础的视图栈操作, 包括 Push, Pop, PopToView, PopToRootView, 它的功能和 UINavigationController 一致, 只是它在一个视图控制器中, 控制显示在视图控制器的视图, 我们可以扩展下它的方法, 比如某层栈视图的横向切换, 例如滴滴业务视图 StackPage1 的左右切换

/// 使用 ViewSwitchAnimation 协议
public class NavigationRoute: ViewSwitchAnimation {
    
    ...
    
    /// StackViewNavigation 属性
    fileprivate var stackViewNavigation: StackViewNavigation
   
    public init(viewController:UIViewController, rootView: UIView) {
        stackViewNavigation = StackViewNavigation(rootView: rootView)
        /// 协议属性 
        self.superView = viewController.view
        self.superViewHeight = viewController.view.bounds.height
    }
    
    /// stackViewNaviation 的一些方法
    public func pushView(_ view: UIView, animated: Bool) -> Bool {
        return stackViewNavigation.pushView(view, animated: animated)
    }
    
    public func popToRootView(animated: Bool) -> Bool {
        return stackViewNavigation.popToRootView(animated: animated) != nil ? true : false
    }
    
    public func popView(animated: Bool) -> Bool {
        return stackViewNavigation.popView(animated: animated) != nil ? true : false
    }
    
    public func popToView(_ view: UIView, animated: Bool) -> Bool {
        return stackViewNavigation.popToView(view, animated: animated) != nil ? true : false
    }
}

通过 NavigationRoute 封装了一层, 我们可以写一些代理方法来记录视图切换的一些事件, 并实现横向切换

public protocol NavigationRouteDelegate: class {
    /// 当前栈顶视图将要显示
    func navigationRoute(route: NavigationRoute, nextViewDidShow nextView: UIView, topView: UIView)
    /// 当前 top 视图将要隐藏
    func navigationRoute(route: NavigationRoute, topViewWillHidden topView: UIView, nextView: UIView)
    /// 视图切换动画中
    func navigationRoute(route: NavigationRoute, nextViewAnimation nextView: UIView, animated: Bool)
}

/// 横向切换视图, pop: 切换到左边动画,  push: 切换到右边的动画
public func switchTopView(direction: StackViewNavigationOperation, nextView: UIView, animated: Bool) -> Bool {
        
        guard !stackViewNavigation.isAnimation else {
            print("switchTopView failure, 前一个动画还未结束")
            return false
        }
        
        guard stackViewNavigation.topView != nextView else {
            print("switchTopView failure, nextView 和 topView 是同一个 view")
            return false
        }
        
        delegate?.navigationRoute(route: self, topViewWillHidden: stackViewNavigation.topView, nextView: nextView)
        
        stackViewNavigation.isAnimation = true
        
        animation(operation: direction, topView: stackViewNavigation.topView, nextView: nextView, animated: animated) { (finished) in
            
            self.stackViewNavigation.topView = nextView
            
            self.stackViewNavigation.isAnimation = false
            
            self.stackViewNavigation.views.removeLast()
            // 防止更换的 topView 是 rootView
            if self.stackViewNavigation.views.count == 0 {
                self.stackViewNavigation.rootView = nextView
            }
            self.stackViewNavigation.views.append(nextView)
            
            let lastTopView = self.stackViewNavigation.lastTopView
            self.delegate?.navigationRoute(route: self, nextViewDidShow: nextView, topView: lastTopView)
            self.stackViewNavigation.lastTopView = nextView
        }
        
        return true
    }

至此, 我们实现了一个视图栈切换功能, switchTopView 方法当然也可以通过传递一个 UIScrollView 或 UICollectionView 来实现, 但是这样的话监听视图切换事件的代理可能就需要外部调用实现, 如果使用内部的 switchTopView 方法, 我们可以把视图切换的所有事件都在内部监听便于外部使用

看看效果:

DiDIStackPage

使用

/// 设置 RootView, 添加到 ViewController 的 view 中完成布局
let rootView = UIView()
view.addSubview(rootView)
rootView.snp.makeConstraints { (maker) in
    maker.top.equalToSuperview().inset(64)
    maker.left.right.bottom.equalToSuperview()
}       

/// 初始化 NavigationRoute, 并设置 topInster 和 bottomInster
navigationRoute = NavigationRoute(viewController: self, rootView: rootView)
navigationRoute.topInster = 64
navigationRoute.setupDelegate()
navigationRoute.delegate = self

/// 视图切换的一些方法
navigationRoute.pushView(view, animated: animated)

navigationRoute.popView(animated: animated)

navigationRoute.popToRootView(animated: animated)

navigationRoute.switchTopView(direction: .push, nextView: view, animated: animated)


/// 代理方法
func navigationRoute(route: NavigationRoute, nextViewDidShow nextView: UIView, topView: UIView) {
    print("nextViewDidShow")
}
    
func navigationRoute(route: NavigationRoute, topViewWillHidden topView: UIView, nextView: UIView) {
    /// 可设置切换后的视图布局
    nextView.topInster = 100
    naxtView.bottomInster = 50
    print("topViewWillHidden")
}
    
func navigationRoute(route: NavigationRoute, nextViewAnimation nextView: UIView, animated: Bool) {
    /// 切换视图动画中... 可把其他联动动画放在这里
    print("nextViewAnimation")
}

这还可以扩展很多方法, 比如上下的切换, 自定义动画, 有兴趣可以自己改造下

项目代码已经放到 github: https://github.com/ZeroJian/NavigationRoute

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

推荐阅读更多精彩内容

  • 1、通过CocoaPods安装项目名称项目信息 AFNetworking网络请求组件 FMDB本地数据库组件 SD...
    X先生_未知数的X阅读 15,937评论 3 118
  • 我看到昏黄的田野 冷漠的夕阳 以及飞驰的工厂 我看到或者看不到熙熙攘攘 我看到许多人跟我一样或者不一样 我看到银色...
    onlyxy阅读 289评论 0 0
  • 阳光,天高气爽, 很美好的早晨,做早饭,孩子吃饱了,送孩子去上学,回来又精心的给先生准备了更丰盛的早餐!吃...
    蝉心安阅读 874评论 7 3
  • 不是每天的头脑都是很清醒的,总有很糊涂的时候,这个时候要对自己宽容一点:)
    Bruceshaoshao阅读 112评论 0 0
  • 生活过的城市对比:深圳 VS 昆明 (没有绝对性,相对而言) 1,职场:深圳的职场人普遍更愿意实际能拿多少钱,不在...
    阿里火焰山阅读 168评论 0 0