iOS多级下拉菜单开发

为接下来我们要做什么有个底,先来看看完成后的效果图。总的来说我们要实现这样一个下拉控件,点击城市按钮,就会弹出城市选择下拉列表,点击第一列或者第二列就会根据点击的行刷新后面的列表,比如点击了省份这一列会更新相应的城市列表和区县列表,大概可以用级联更新这个词来描述吧!在点击最后一列后列表会消失,点击灰色区域城市列表也会消失,在列表展示的情况下,点击城市按钮,城市列表也会消失。停止哔哔🤣😂,撸起袖子开始干!

我们这次的重点是多级下拉列表的开发,因此我们从一个初始项目开始,直接进入下拉列表的开发的开发环节🤡🤡。从这里下载我们的初始项目,下图是初始项目运行后的结果。

做一个小小的说明:

  • 这次我们项目中使用SnapKit以纯代码的方式进行界面布局,不熟悉的朋友不同担心,我们的Demo项目界面比较简单,布局代码也是简洁明了的。
  • 另外城市列表数据格式为json,我们通过Unbox第三方库,将json数据转成项目中可以使用的Model
  • 这两个库已经通过pod安装,包含在初始项目中,初始列表还包括城市列表数据

从此刻开始我们的多级下拉控件叫做MultiLevelMenu

我们使用多个UITableView来实现多个列表,从完成效果图来看,这里有3个UITableView对象,可是为了把这个MultiLevelMenu控件做成一个通用控件,我们需要让外界提供给数据给MultiLevelMenu,然后进行展示。类似UITableView的数据源方法,我们也会创建DataSource 协议,让外界通过这个协议为MultiLevelMenu提供数据。

好开心,好开心,终于可以敲代码了😈😈😈!

打开项目文件导航栏,选中View这个Group,(command + n)创建一个UIView的子类MultiLevelMenu,这个就是我们的多级下拉列表啦!刚刚的操作可以看一看下面的步骤截图。

打开MultiLevelMenu.swift文件,添加初始化方法,在这里我们将背景色设置为黑色,透明度为0.5。这样做的目的是,在展示MultiLevelMenu控件时,会有一层灰黑色蒙版,具体可以参考完成后的效果图。

import UIKit

class MultiLevelMenu: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = UIColor(white: 0, alpha: 0.5)
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

在类MultiLevelMenu定义的上方,添加一个数据源协议MultiLevelMenuDataSource,声明如下

@objc protocol MultiLevelMenuDataSource: NSObjectProtocol {
    @objc func numberOfLevel(of multiLevelMenu: MultiLevelMenu) -> Int
}

现在MultiLevelMenuDataSource声明中只有一个方法,外界通过这个数据源方法,告诉MultiLevelMenu要显示多少个级别的数据,比如返回3,那么就会有3个列表。MultiLevelMenu通过这个数据源方法,配置列表和数据。

数据源协议声明好了,我们在MultiLevelMenu类中添加一个dataSource属性,代表数据源。

weak var dataSource: MultiLevelMenuDataSource?

暂时把数据源放一放,为了在table view上展示城市信息,定义一个UITableViewCell的子类LevelItemCell,在MultiLevelMenuDataSource的声明下面定义这个类。这个cell非常简单,只包含了一个label控件,设置了label的文字居中。同时为LevelItemCell增加了紫色选中效果。

fileprivate class LevelItemCell: UITableViewCell {
    
    lazy var levelNamelabel: UILabel = {
        let label = UILabel()
        label.font = UIFont.systemFont(ofSize: 14)
        label.textAlignment = .center
        return label
    }()
    
    override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        self.backgroundColor = UIColor.clear
        
        // 布局label
        self.contentView.addSubview(levelNamelabel)
        levelNamelabel.snp.makeConstraints { (make) in
            make.edges.equalToSuperview().inset(UIEdgeInsetsMake(8, 12, 8, 12))
        }
        
        // 添加紫色选中效果
        let view = UIView()
        view.backgroundColor = UIColor(red: 198 / 255.0, green: 165 / 255.0, blue: 223 / 255.0, alpha: 0.3)
        self.selectedBackgroundView = view
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

好了,有了LevelItemCell,接着就要在table view中展示它们了。回到MultiLevelMenu类中,在init(frame: CGRect)初始化方法的上方,添加3个私有变量,分别代表cell的重用标识符,有多少级列表需要展示,以及存储展示的列表。

fileprivate let LevelItemCellReuseIdentifier = "LevelItemCellReuseIdentifier"
fileprivate lazy var numberOfLvel = 0
fileprivate lazy var menuTableViews = [UITableView]()

再添加一个容器view,我们的城市列表都会放在这个容器view中,方便管理和实现一些动画效果。在menuTableViews变量的下方,创建这个view

private lazy var containerView: UIView = {
    let view = UIView()
    return view
}()

接下来我们在MultiLevelMenu类中添加两个私有方法,用来创建展示城市数据的table view,以及对table view进行布局。

// 创建table view
private func createMenuTableView() {
    for level in 0..<self.numberOfLvel {
        let tableView = UITableView()
        tableView.register(LevelItemCell.self, forCellReuseIdentifier: LevelItemCellReuseIdentifier)
        tableView.tag = 100 + level
        tableView.rowHeight = UITableViewAutomaticDimension
        tableView.estimatedRowHeight = 40
        tableView.tableFooterView = UIView()
        tableView.showsVerticalScrollIndicator = false
        tableView.separatorColor = UIColor(white: 0.9, alpha: 1.0)
        tableView.separatorInset = UIEdgeInsetsMake(0, 0, 0, 0)
        tableView.dataSource = self
        tableView.delegate = self
        
        menuTableViews.append(tableView)
    }
    
    configureLayout()
}

// 布局table view
private func configureLayout() {
    self.addSubview(containerView)
    for (index, tableView) in menuTableViews.enumerated() {
        containerView.addSubview(tableView)
        
        tableView.snp.makeConstraints({ (make) in
            make.top.bottom.equalToSuperview()
            make.width.equalToSuperview().multipliedBy(1.0 / Double(self.numberOfLvel))
            
            if index == 0 {
                make.leading.equalToSuperview()
            } else {
                make.leading.equalTo(self.menuTableViews[index - 1].snp.trailing)
            }
        })
    }
    
    containerView.snp.makeConstraints { (make) in
        make.leading.top.trailing.equalToSuperview()
        make.height.equalTo(240)
    }
}

创建table view这个方法很好懂,在这里对table view做了一些常规的配置,由于我们还没有实现UITableView的数据源方法和delegate方法,这里编译器会有错误提示,这个不要紧,我们后面加上去。

在布局table view的方法中,为每个table view添加约束,table view的top和bottom和containerView一致;width为containerView的1.0 / numberOfLvel,也就是说如果有3个级别,那么每个列表的宽度为containerView的1/3;table view的左边的约束设置与containerView的左边相等或者与前一级列表的右边相等。最后设置containerView的左边,上边,右边约束与MultiLevelMenu对象本身相等,height约束为240,这个可以根据需要改变。希望我有解释清楚这个方法😂😂。

现在我们使用extension的方式为MultiLevelMenu添加UITableView的数据源方法和delegate方法。在MultiLevelMenu类最后的花括号下面添加以下代码段。

extension MultiLevelMenu: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 5
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let level = tableView.tag - 100
        let cell = tableView.dequeueReusableCell(withIdentifier: LevelItemCellReuseIdentifier, for: indexPath) as! LevelItemCell
        cell.levelNamelabel.text = "level \(level)-\(indexPath.row)"
        return cell
    }
}

extension MultiLevelMenu: UITableViewDelegate {
    
}

这里我们添加这两段代码纯粹是为了验证能否将MultiLevelMenu正确的展示出来。

我们为MultiLevelMenu的dataSource属性添加一个属性观察器,当dataSource被设置之后,我们就调用数据源方法numberOfLevel(of multiLevelMenu: MultiLevelMenu),获得需要展示多少个级别的信息并创建table view。代码如下

weak var dataSource: MultiLevelMenuDataSource? {
    didSet {
        guard let dataSource = dataSource else {
            return
        }
        
        numberOfLvel = dataSource.numberOfLevel(of: self)
        createMenuTableView()
    }
}

为了有个参考,贴出到目前为止整个MultiLevelMenu.swift文件的代码。

import UIKit

@objc protocol MultiLevelMenuDataSource: NSObjectProtocol {
    @objc func numberOfLevel(of multiLevelMenu: MultiLevelMenu) -> Int
}

fileprivate class LevelItemCell: UITableViewCell {
    
    lazy var levelNamelabel: UILabel = {
        let label = UILabel()
        label.font = UIFont.systemFont(ofSize: 14)
        label.textAlignment = .center
        return label
    }()
    
    override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        self.backgroundColor = UIColor.clear
        
        // 布局label
        self.contentView.addSubview(levelNamelabel)
        levelNamelabel.snp.makeConstraints { (make) in
            make.edges.equalToSuperview().inset(UIEdgeInsetsMake(8, 12, 8, 12))
        }
        
        // 添加紫色选中效果
        let view = UIView()
        view.backgroundColor = UIColor(red: 198 / 255.0, green: 165 / 255.0, blue: 223 / 255.0, alpha: 0.3)
        self.selectedBackgroundView = view
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

class MultiLevelMenu: UIView {
    
    //MARK: - 公开变量
    weak var dataSource: MultiLevelMenuDataSource? {
        didSet {
            guard let dataSource = dataSource else {
                return
            }
            
            numberOfLvel = dataSource.numberOfLevel(of: self)
            createMenuTableView()
        }
    }
    
    //MARK: - 私有变量
    fileprivate let LevelItemCellReuseIdentifier = "LevelItemCellReuseIdentifier"
    fileprivate lazy var numberOfLvel = 0
    fileprivate lazy var menuTableViews = [UITableView]()
    
    private lazy var containerView: UIView = {
        let view = UIView()
        return view
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = UIColor(white: 0, alpha: 0.5)
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // 创建table view
    private func createMenuTableView() {
        for level in 0..<self.numberOfLvel {
            let tableView = UITableView()
            tableView.register(LevelItemCell.self, forCellReuseIdentifier: LevelItemCellReuseIdentifier)
            tableView.tag = 100 + level
            tableView.rowHeight = UITableViewAutomaticDimension
            tableView.estimatedRowHeight = 40
            tableView.tableFooterView = UIView()
            tableView.showsVerticalScrollIndicator = false
            tableView.separatorColor = UIColor(white: 0.9, alpha: 1.0)
            tableView.separatorInset = UIEdgeInsetsMake(0, 0, 0, 0)
            tableView.dataSource = self
            tableView.delegate = self
            
            menuTableViews.append(tableView)
        }
        
        configureLayout()
    }
    
    // 布局table view
    private func configureLayout() {
        self.addSubview(containerView)
        for (index, tableView) in menuTableViews.enumerated() {
            containerView.addSubview(tableView)
            
            tableView.snp.makeConstraints({ (make) in
                make.top.bottom.equalToSuperview()
                make.width.equalToSuperview().multipliedBy(1.0 / Double(self.numberOfLvel))
                
                if index == 0 {
                    make.leading.equalToSuperview()
                } else {
                    make.leading.equalTo(self.menuTableViews[index - 1].snp.trailing)
                }
            })
        }
        
        containerView.snp.makeConstraints { (make) in
            make.leading.top.trailing.equalToSuperview()
            make.height.equalTo(240)
        }
    }
}

extension MultiLevelMenu: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 5
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let level = tableView.tag - 100
        let cell = tableView.dequeueReusableCell(withIdentifier: LevelItemCellReuseIdentifier, for: indexPath) as! LevelItemCell
        cell.levelNamelabel.text = "level \(level)-\(indexPath.row)"
        return cell
    }
}

extension MultiLevelMenu: UITableViewDelegate {
    
}

接着我们切换到HomeViewController.swift文件,对MultiLevelMenu做个简单的展示验证。在createSeparateLine()方法下方添加一个presentMultiLevelMunu(sender: UIButton)方法。

@objc private func presentMultiLevelMunu(sender: UIButton) {
    let multiLevelMenu = MultiLevelMenu(frame: CGRect(x: 0, y: 120, width: self.view.frame.width, height: 500))
    multiLevelMenu.dataSource = self
    self.view.addSubview(multiLevelMenu)
}

找到cityButton定义的地方,用下面的代码段替换它。点击cityButton将会展示MultiLevelMenu。

private lazy var cityButton: UIButton = {
    let button = self.createButton(title: "城市")
    button.addTarget(self, action: #selector(presentMultiLevelMunu(sender:)), for: .touchUpInside)
    return button
}()

最后实现MultiLevelMenu的数据源方法,告诉MultiLevelMenu需要展示多少级数据。

extension HomeViewController: MultiLevelMenuDataSource {
    func numberOfLevel(of multiLevelMenu: MultiLevelMenu) -> Int {
        return 3
    }
}

🐶激动人心的时刻来啦,运行一次看看效果🐹。点击城市按钮之后,可以看到我们已经将MultiLevelMenu展示出来了🐵🐼。

这里我们是直接把MultiLevelMenu添加到当前controller的view上的,接下来我们换一种方式来展示MultiLevelMenu,在UIWindow上添加MultiLevelMenu。切换到MultiLevelMenu.swift文件,添加一个变量用来记录MultiLevelMenu是否已经弹出,再添加两个方法,分别用来弹出和移除MultiLevelMenu。

  • func presnt(from view: UIView),从一个view的下方弹出MultiLevelMenu,比如传进来一个button,那么将在这个button的下方显示MultiLevelMenu控件。
  • func dismiss(animated: Bool) 将MultiLevelMenu控件从UIWindow移除。
  • var isShowed = false,记录MultiLevelMenu的显示状态
func presnt(from view: UIView) {
    var tmpSuperView = view
    
    // 寻找最上级view
    while tmpSuperView.superview != nil {
        tmpSuperView = tmpSuperView.superview!
    }
    
    let window = UIApplication.shared.keyWindow
    window?.addSubview(self)
    
    // 进行坐标变化,得到控件左下角相对于最上级view的坐标
    var presentPoint: CGPoint
    if view.superview == tmpSuperView {
         presentPoint = CGPoint(x: view.frame.minX, y: view.frame.maxY)
    } else {
         presentPoint = tmpSuperView.convert(CGPoint(x: view.frame.minX, y: view.frame.maxY), from: view)
    }
    
    self.snp.makeConstraints { (make) in
        make.top.equalToSuperview().offset(presentPoint.y)
        make.leading.trailing.bottom.equalToSuperview()
    }
    
    isShowed = true
}
    
func dismiss(animated: Bool) {
    self.removeFromSuperview()
    isShowed = false
}

这里有必要对func presnt(from view: UIView)解释一下,传进来一个view,我们需要找到它最上级的view(也就是superView),比如下面的示例图,view2和view3的最上级view都是view1,这里就涉及了一个坐标系问题,view3的坐标是相对于view2,而view2的坐标是相对于view1的。假如我们现在从view3弹出MultiLevelMenu,那么就需要将view3的坐标变换到view1的坐标系中,如果从view2弹出MultiLevelMenu,则不需要进行坐标变换,因为view2的坐标本身就是相对于view1的。

我们在func presnt(from view: UIView)方法中将MultiLevelMenu添加到window上,对MultiLevelMenu添加约束,使MultiLevelMenu显示的位置刚好在传进来view的下方,并占满剩余的空间。

切换到HomeViewController.swift文件,让我们来试试,刚刚添加的方法。

在viewDidLoad()方法的上方添加一个MultiLevelMenu的示例变量。

private lazy var multiLevelMenu: MultiLevelMenu = {
    let multiLevelMenu = MultiLevelMenu()
    multiLevelMenu.dataSource = self
    return multiLevelMenu
}()

接着我们修改presentMultiLevelMunu(sender: UIButton)方法,如果MultiLevelMenu没有弹出,我们就显示它,如果已经显示,我们就移除它。

@objc private func presentMultiLevelMunu(sender: UIButton) {
    multiLevelMenu.isShowed ? multiLevelMenu.dismiss(animated: true) : multiLevelMenu.presnt(from: sender)
}

运行一遍试试吧😁😁!我们正确的将MultiLevelMenu,显示在cityButton的下方,而且显示,移除都没有问题。

接下来我们需要为MultiLevelMenu,填充点真实的数据了,这部分才是我们真正的重点,所以打起精神吧少年!!。

看看我们每一级的table view需要显示什么数据,可以发现只要显示某个level的名称就够了,选中一个level就会显示所有下一级的名称,因此我们可以定义一个Level类,来代表每一个需要显示的Level节点。

class Level {
    var levelName = ""
    var netLevelItems: [Level]?
}

我们可以让具体的Model去继承Level类,在MultiLevelMenu的使用上只需要知道levelName和对应的下一级就够了,所以MultiLevelMenu能够把Level类或者它的子类作为自己的数据源。

为了践行面向协议编程的思想,这里不使用继承的方式实现MultiLevelMenu的数据源结构,而将Level类以协议的方式实现。在View这个group中新建一个swift文件,命名为 LeveItemProtocol.swift,在这个文件中,我们定义LeveItemProtocol。

import Foundation

@objc protocol LeveItemProtocol: NSObjectProtocol {
    var levelName: String { get }
    var nextLevelItems: [LeveItemProtocol]? { get }
}

LeveItemProtocol定义了两个属性,当前级别的名称和下一级别,如果当前级别是最后一级,那么下一级就是nil,所以下一级是optional类型的。

接下来我们将定义一个Model,用来表示城市信息。打开Resources这个group中的china-city-info.json文件,可以看到大量的json格式的城市信息数据,这里会使用到Unbox第三方库将JSON数据转为模型。

在Model这个group中新建一个swift文件,命名为CityModel.swift,将文件内容替换为下面的代码段。

import Foundation
import Unbox

class CityModel: NSObject, Unboxable, LeveItemProtocol {
    
    let name: String
    let subCitys: [CityModel]?
    
    var levelName: String {
        return self.name
    }
    
    var nextLevelItems: [LeveItemProtocol]? {
        return self.subCitys
    }
    
    required init(unboxer: Unboxer) throws {
        name = try unboxer.unbox(key: "name")
        subCitys = unboxer.unbox(key: "sub")
    }
}

简单的说明一下这个model,这里继承NSObject的原因是,LeveItemProtocol继承了NSObjectProtocol,而NSObject对NSObjectProtocol提供了默认实现,继承了NSObject也就满足了NSObjectProtocol的要求;Unboxable协议用来json转model,LeveItemProtocol可以让CityModel做为MultiLevelMenu的数据源。对于LeveItemProtocol的实现,我们这里是返回当前城市名称和下一级城市。实际中可能每个项目的model会和这里的有很大差别,可以根据自己的需求进行调整。

在ViewModel这个group中,创建一个CityViewModel.swift文件,我们将在这里把json数据转成我们可以使用的model,在这个文件中,添加以下代码。

import Foundation
import Unbox

class CityViewModel {
    
    lazy var cityArray: [CityModel]? = self.convertJSONDataToCityModel()
    
    private func convertJSONDataToCityModel() -> [CityModel]? {
        guard let filePath = Bundle.main.url(forResource: "china-city-info", withExtension: "json"),
            let data = try? Data(contentsOf: filePath) else {
                return nil
        }
        
        do {
            let cityArray: [CityModel] = try unbox(data: data)
            return cityArray
        } catch {
            print(error.localizedDescription)
        }
        
        return nil
    }
    
}

一个对外公开的懒惰变量,通过这个变量获取到城市数组。实际工作由func convertJSONDataToCityModel()方法完成,在这里我们得到城市数据文件的路径,再用Data的初始化方法得到Data类型的数据,最后使用unbox的初始化方法,将json数据转化为城市数组。在路径错误,或者转换失败的情况下都会返回nil。

现在来看看我们是否正确的获取到了城市数据,切换到HomeViewController.swift文件,在viewDidLoad()方法的最后添加两行代码,并在最后的花括号中打个断点。

运行到断点处,查看控制台,可以看到我们确实正确的获得了城市数据。

删掉刚刚添加的两行代码和断点。接着我们切换到MultiLevelMenu.swift文件,我们需要为MultiLevelMenuDataSource声明一个新的数据方法func firstLevelItems(of multiLevelMenu: MultiLevelMenu) -> [LeveItemProtocol]?,MultiLevelMenu将通过这个数据源方法获得真正展示的数据。

@objc protocol MultiLevelMenuDataSource: NSObjectProtocol {
    @objc func numberOfLevel(of multiLevelMenu: MultiLevelMenu) -> Int
    @objc func firstLevelItems(of multiLevelMenu: MultiLevelMenu) -> [LeveItemProtocol]?
}

从方法名可以大概知道,这个方法让外界返回需要展示的第一级数据,为什么只要第一数据就够了呢??因为这里的每一级数据都实现了LeveItemProtocol,所以我们能够通过nextLevelItems属性来得到下一级需要展示的数据。

在我们声明这个数据源方法的同时,Xcode也报错了,告诉我们HomeViewController没有遵守MultiLevelMenuDataSource协议。那我们就回到HomeViewController.swift文件,实现这个数据源方法。在HomeViewController的extension部分,添加这个数据源方法,在方法中,我们返回通过CityViewModel得到的城市数组数据。

func firstLevelItems(of multiLevelMenu: MultiLevelMenu) -> [LeveItemProtocol]? {
    return CityViewModel().cityArray
}

现在回到MultiLevelMenu.swift文件,为MultiLevelMenu类添加以下两个属性。

fileprivate var firstLevelItems: [LeveItemProtocol]?
fileprivate lazy var selectedLevelItems = [LeveItemProtocol?]()
  • firstLevelItems 记录第一级数据,通过第一级数据可以获取后续级别的数据,所及我们对第一级数据做一个引用
  • selectedLevelItems 记录选中的级别项,比如选中北京,海淀区,那么第一项是北京,第二项是海淀区,对应城市的第一级和第二级。

现在假设我们的MultiLevelMenu已经得到了第一级城市数据,我们还要展示第二级和第三级数据,因此我们需要对得到的第一级数据做一些处理。添加一个func handleLevelData()方法处理数据。在这里我们默认选中每一级的第一项,这就是for循环的作用。

private func handleLevelData() {
    guard let firstLevelItems = firstLevelItems else {
        return
    }
    
    for level in 0..<numberOfLvel {
        if selectedLevelItems.count == 0 {
            selectedLevelItems.append(firstLevelItems[0])
        } else {
            if let nextLevelItems = selectedLevelItems[level - 1]?.nextLevelItems {
                selectedLevelItems.append(nextLevelItems[0])
            } else {
                selectedLevelItems.append(nil)
            }
        }
    }
}

接下来我们需要对MultiLevelMenu类进行两处修改。首先让我们我们找到dataSource这个属性,修改它的属性观察者。

weak var dataSource: MultiLevelMenuDataSource? {
    didSet {
        guard let dataSource = dataSource else {
            return
        }
        
        numberOfLvel = dataSource.numberOfLevel(of: self)
        firstLevelItems = dataSource.firstLevelItems(of: self)
        handleLevelData()
        createMenuTableView()
    }
}

数据已经处理好,下一步我们修改tableview的数据源方法,将数据展示出来。修改如下。

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    let level = tableView.tag - 100
    if level == 0 {
        return firstLevelItems?.count ?? 0
    } else {
        return selectedLevelItems[level - 1]?.nextLevelItems?.count ?? 0
    }
}
    
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
    let level = tableView.tag - 100
    
    var levelItem: LeveItemProtocol?
    if level == 0 {
        levelItem = firstLevelItems?[indexPath.row]
    } else {
        levelItem = selectedLevelItems[level - 1]?.nextLevelItems?[indexPath.row]
    }
    
    let cell = tableView.dequeueReusableCell(withIdentifier: LevelItemCellReuseIdentifier, for: indexPath) as! LevelItemCell
    
    cell.levelNamelabel.text = levelItem?.levelName
    
    return cell
}

我们通过选中的级别来判断需要显示在tableview中多少行,在这里第一级别为省,我们直接返回第一级别的数目,对于第二级别(相对于省来说为市这个级别),我们需要根据选中的第一级别来判断需要显示多少行,通过上一级的nextLevelItems属性,可以获得该级别的所有下一级项,我们返回所有下一级的数量。对于第三级别也用同样的方法得到需要显示的行数。得到下一级数量的逻辑是在else子句中。

有了行数之后,我们简单的对cell配置,我们根据当前显示的级别,获得需要显示的条目,这部分逻辑和获得行数的逻辑是几乎一样的,最后我们将级别的名称设置到cell上。

运行一遍看看效果。

可以看到我们正确的展示了城市数据,目前默认显示的是北京地区。只是现在还有一些问题,点击任何一个城市并没有更新对应的区域。现在我们就来解决这个问题。

滚动到文件的末尾,找到MultiLevelMenu的UITableViewDelegate这个extension部分,我们在这里实现func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)代理方法,这个方法在tableview的某一行被选中时会被调用。添加以下代码段。

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let level = tableView.tag - 100
    updateNextLevelItems(selectedLevel: level, on: indexPath)
}
    
// 选中某一级别,需要更新对应的下一级别
func updateNextLevelItems(selectedLevel: Int, on indexPath: IndexPath) {
    // 更新对应的下一级别
    if selectedLevel == 0 {
        selectedLevelItems[0] = firstLevelItems?[indexPath.row]
    } else {
        selectedLevelItems[selectedLevel] = selectedLevelItems[selectedLevel - 1]?.nextLevelItems?[indexPath.row]
    }
    
    // 后续选中的下一级别默认为第一项
    for level in (selectedLevel+1)..<numberOfLvel {
        selectedLevelItems[level] = selectedLevelItems[level - 1]?.nextLevelItems?.first
    }
    
    // 刷新后续的tableview
    for level in (selectedLevel+1)..<numberOfLvel {
        menuTableViews[level].reloadData()
    }
}

在这里我们对选中级别做了更新,并且刷新对应的tableview。完成之后,再次运行程序,现在选中任何城市,都能够显示对应的城市信息。下图是选中广东省,珠海市的结果。

到这里为止,我们大部分的工作已经完成了。接下来我们还可以为MultiLevelMenu声明代理协议,通过这些代理协议,我们可以制定MultiLevelMenu的外观,以及告知外界我们选中的级别信息。找到MultiLevelMenuDataSource数据源协议声明的位置,在它的下方添加MultiLevelMenuDelegate代理协议的声明。

@objc protocol MultiLevelMenuDelegate: NSObjectProtocol {
    
    @objc optional func multiLevelMenu(multiLevelMenu: MultiLevelMenu, backgroundColorForLevel level: Int) -> UIColor
    @objc optional func multiLevelMenu(multiLevelMenu: MultiLevelMenu, widthRatioForLevel level: Int) -> CGFloat
    
    @objc optional func multiLevelMenu(multiLevelMenu: MultiLevelMenu, didSelectedLastLevel selectedLevelItems: [LeveItemProtocol])
}

这个三个代理方法都是可选的,没有必要全部实现。从上之下每个方法的作用为:

  • 从外界得到每一级别tableview的背景色
  • 得到每一级别tableview的显示宽度比例
  • 告知外界我们选中的级别

代理协议声明好之后,我们为MultiLevelMenu添加一个delegate变量,以及两个方法,分别用来改变tableview的背景色和显示宽度,这两个方法会在从外界获得背景色和显示宽度比例时被调用。

首先声明delegate变量

weak var delegate: MultiLevelMenuDelegate?

再添加调整背景色和宽度的方法

private func changeTableViewBackgroudColor() {
    for level in 0..<numberOfLvel {
        let backgroundColor = self.delegate!.multiLevelMenu!(multiLevelMenu: self, backgroundColorForLevel: level)
        menuTableViews[level].backgroundColor = backgroundColor
    }
}
    
private func remakeTableViewConstraints() {
    for level in 0..<numberOfLvel {
        let widthRatio = self.delegate!.multiLevelMenu!(multiLevelMenu: self, widthRatioForLevel: level)
        let tableView = menuTableViews[level]
        
        tableView.snp.remakeConstraints({ (make) in
            make.top.bottom.equalToSuperview()
            make.width.equalToSuperview().multipliedBy(widthRatio)
            
            if level == 0 {
                make.leading.equalToSuperview()
            } else {
                make.leading.equalTo(self.menuTableViews[level - 1].snp.trailing)
            }
        })
    }
}

在这两个方法中,我们对delegate和可选方法进行了强制解包,这里本来应该做判空处理的,待会我们就可以知道为什么我们不需要在这里做判空处理。我们调整tableview的宽度,是通过设置宽度约束来实现的。

现在我们找到delegate变量,为它增加属性观察器.

weak var delegate: MultiLevelMenuDelegate? {
    didSet {
        guard let delegate = delegate else {
            return
        }
        
        if delegate.responds(to: #selector(MultiLevelMenuDelegate.multiLevelMenu(multiLevelMenu:backgroundColorForLevel:))) {
            changeTableViewBackgroudColor()
        }
        
        if delegate.responds(to: #selector(MultiLevelMenuDelegate.multiLevelMenu(multiLevelMenu:backgroundColorForLevel:))) {
            remakeTableViewConstraints()
        }
    }
}

在delegate变量被设置之后,我们去改变tableview的背景色和显示宽度,我们在这里做了判空操作,这就是为什么我们不需要在前面两个方法中进行判空操作的原因。

切换到HomeViewController.swift文件,为HomeViewController添加MultiLevelMenuDelegate协议的实现。

extension HomeViewController: MultiLevelMenuDelegate {
    func multiLevelMenu(multiLevelMenu: MultiLevelMenu, backgroundColorForLevel level: Int) -> UIColor {
        if level == 0 {
            return UIColor(white: 0.92, alpha: 1.0)
        } else if level == 1 {
            return UIColor(white: 0.94, alpha: 1.0)
        } else {
            return UIColor(white: 0.96, alpha: 1.0)
        }
    }
    
    func multiLevelMenu(multiLevelMenu: MultiLevelMenu, widthRatioForLevel level: Int) -> CGFloat {
        if level == 0 {
            return 0.24
        } else if level == 1 {
            return 0.38
        } else {
            return 0.38
        }
    }
}

这里的颜色和宽度比例可以根据需求调整。最后不要忘记设置MultiLevelMenu对象的delegate属性为self,找的multiLevelMenu的声明处,添加一行代码。

multiLevelMenu.delegate = self

最后运行我们的程序看看效果,看起来效果还是可以的。

接下来我们实现MultiLevelMenuDelegate协议的最后一个代理方法

在我们刚刚的extension部分,实现最后一个代理方法,这里我们只是简单的打印级别名称。

func multiLevelMenu(multiLevelMenu: MultiLevelMenu, didSelectedLastLevel selectedLevelItems: [LeveItemProtocol]) {
    for levelItem in selectedLevelItems {
        print(levelItem.levelName)
    }
}

回到MultiLevelMenu.swift文件,是时候告知外界我们选中的级别信息了。修改tableview选中某一行的代理方法。

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let level = tableView.tag - 100
    updateNextLevelItems(selectedLevel: level, on: indexPath)
    
    // 检查是否选中了最后一个级别,并对delegate和可选方法进行判空操作
    if (selectedLevelItems[level]?.nextLevelItems == nil)
        && (delegate != nil)
        && (delegate!.responds(to: #selector(MultiLevelMenuDelegate.multiLevelMenu(multiLevelMenu:didSelectedLastLevel:)))) {
        delegate?.multiLevelMenu!(multiLevelMenu: self, didSelectedLastLevel: selectedLevelItems.filter{ $0 != nil } as! [LeveItemProtocol])
        dismiss(animated: true)
    }
}

在这里如果我们选中了最后一级就告诉外界我们选中的级别,并移除MultiLevelMenu。运行看看效果,这里我第一次选中了北京的朝阳区,第二次选中了广东佛山的三水区,都正确的在控制台打印出来了。

我们还可以为MultiLevelMenu的展现和移除添加动画。首先我们找到presnt(from view: UIView)方法,在这个方法的最后,添加以下几行代码。

// 1.改变约束让containerView的位置刚好在MultiLevelMenu控件的上方,并使约束立即生效
self.containerView.snp.remakeConstraints { (make) in
    make.leading.trailing.equalToSuperview()
    make.height.equalTo(240)
    make.bottom.equalTo(self.snp.top)
}
self.layoutIfNeeded()
self.alpha = 0.0
    
// 2.恢复containerView到原来的位置,使用动画让约束逐步生效
self.containerView.snp.remakeConstraints { (make) in
    make.leading.top.trailing.equalToSuperview()
    make.height.equalTo(240)
}
    
UIView.animate(withDuration: CATransaction.animationDuration()) { 
    self.alpha = 1.0
    self.layoutIfNeeded()
}

我们这里设置通过让containerView从上往下移动,以及改变MultiLevelMenu控件的透明度,产生一个动画。

我们还需要在MultiLevelMenu的类的初始化中添加一行代码,它让超出MultiLevelMenu控件的内容不可见。

self.clipsToBounds = true

现在修改func dismiss(animated: Bool)方法,并添加处理触摸事件的方法。

func dismiss(animated: Bool) {
    if animated {
        UIView.animate(withDuration: CATransaction.animationDuration(), animations: { 
            self.alpha = 0.0
        }, completion: { (_) in
            self.removeFromSuperview()
        })
    } else {
        self.removeFromSuperview()
    }
    
    isShowed = false
}
    
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    dismiss(animated: true)
}

在移除MultiLevelMenu控件时,我们先将它的透明度变为0,完成之后再移除。运行看看效果吧,到这里我们的整个demo就算是完成了😃😃。

为了方便对照,可以在这里下载demo。

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

推荐阅读更多精彩内容

  • 大多数时候,我们说的越多,彼此的距离越远,矛盾也越多。在沟通中,大多数人总是急于表达自己,一吐为快,却一点也不懂对...
    无怨无悔的洒脱阅读 206评论 0 0
  • 【看译文才懂的句子】 1. “And even the miserable lives we lead are n...
    若湖_yuki阅读 740评论 0 2
  • 罐头之所以能够长期保存而不变质,完全得益于密封的容器和严格的杀菌,与防腐剂毫无关系。 罐头其实并不神秘,罐头的原理...
    筱小丽阅读 399评论 0 4
  • 曾经有一段时间,我觉得自己是不配拥有的爱情的。 一段好的感情,应当是两个人在彼此的感情里相互成长,变得更好。...
    卡卡kk阅读 368评论 0 0