EmptyDataSet-Swift学习笔记

今天是2019年11月1号,对于我来说是一个崭新的开始。

前几天由于测试的要求,当列表页面无数据时不要展示一个白板,显得太空旷,放一个背景图,或者文字提示什么的,其实这种需求在很多APP上都有并不罕见,接到需求就着手开始做了。新项目采用的是swift语言进行开发,以前类似的功能用的是一个OC的库,想着既然用swift开发那就尽量不要和OC沾亲带故了,但由于时间紧迫自己写又来不及,后来就发现了这个三方库,用起来还挺灵活,当时由于时间紧迫没有仔细阅读源码就直接用了,其实这种方式还是很不好的,使用一个自己完全不了解的库,感觉就像在身边放了一个定时炸弹一样,总担心哪天出问题,所以今天趁着不忙就来学习一下作者的思想。

协议和委托

协议定义了一个蓝图,规定了用来实现某一特定任务或者功能的方法、属性,以及其他需要的东西。协议可以要求遵循协议的类型提供特定名称和类型的实例属性或类型属性。协议不指定属性是存储型属性还是计算型属性,它只指定属性的名称和类型。此外,协议还指定属性是可读的还是可读可写的。协议可以要求遵循协议的类型实现某些指定的实例方法或类方法。

委托是一种设计模式,它允许类或结构体将一些需要它们负责的功能委托给其他类型的实例。委托模式的实现很简单:定义协议来封装那些需要被委托的功能,这样就能确保遵循协议的类型能提供这些功能。委托模式可以用来响应特定的动作,或者接收外部数据源提供的数据,而无需关心外部数据源的类型。

之所以先说协议和委托,因为这个库主要的思想就是代理。使用方法很简单

 tableView.msEmpty = MSEmptyView(verticalOffset: 0, tapClosure: { [weak self] in
       
  })

通过这一句代码就可以使用功能,效果图如下


4853702-02bbc18c81a22f3a.png

MSEmptyView这个类的代码实现

extension UIScrollView {
    
    private struct AssociatedKeys {
        static var msEmptyKey: Void?
    }
    
    var msEmpty: MSEmptyView? {
        get {
            XLog(AssociatedKeys.msEmptyKey)
            return objc_getAssociatedObject(self, &AssociatedKeys.msEmptyKey) as? MSEmptyView
        }
        set {
            self.emptyDataSetDelegate = newValue
            self.emptyDataSetSource = newValue
            objc_setAssociatedObject(self, &AssociatedKeys.msEmptyKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    
    }
}

首先利用Runtime机制,在UIScrollView的分类里给它添加一个msEmpty属性,类型为MSEmptyView。在set方法里我们将MSEmptyView类型的示例赋值给UIScrollView的emptyDataSetDelegate和emptyDataSetSource,这两个属性是在EmptyDataSet.swift这个类里采用同样的方式添加的

MSEmptyView具体实现

class MSEmptyView {
    
    var image: UIImage?//展示的图片
    
    var allowShow: Bool = false //是否显示

    var verticalOffset: CGFloat = 0 //图片的上线偏移量,用来调整图片的位置
    
    private var tapClosure: (() -> Void)?

    init(image: UIImage? = UIImage(named: "nodata"), verticalOffset: CGFloat = 0, tapClosure: (() -> Void)?) {
        
        self.image = image
        self.verticalOffset = verticalOffset
        self.tapClosure = tapClosure
    }
    

类的实现很简单,三个属性image用来配置展示的图片,allowShow决定是否显示,verticalOffset图片上下的偏移量用来调整位置,默认为0,放在中间。

分类的实现

extension MSEmptyView: EmptyDataSetSource, EmptyDataSetDelegate {
 
    //返回标题
    func title(forEmptyDataSet scrollView: UIScrollView) -> NSAttributedString? {
        
        let placeholserAttributes = [NSAttributedString.Key.foregroundColor : grayFontColor,NSAttributedString.Key.font: F13]
        
        return NSAttributedString(string: "空空如也",attributes: placeholserAttributes)
    }
    
    //返回图片偏移量
    func verticalOffset(forEmptyDataSet scrollView: UIScrollView) -> CGFloat {
        return verticalOffset
    }
    
    //返回展示图片
    func image(forEmptyDataSet scrollView: UIScrollView) -> UIImage? {
        return image
    }
    
    //显示状态
    func emptyDataSetShouldDisplay(_ scrollView: UIScrollView) -> Bool {
        return allowShow
    }
    
    //点击时回调
    func emptyDataSet(_ scrollView: UIScrollView, didTapView view: UIView) {
        guard let tapClosure = tapClosure else { return }
        tapClosure()
    }
}

MSEmptyView遵循了EmptyDataSetSource协议和EmptyDataSetDelegate。
EmptyDataSetSource:定义了很多方法来提供数据,包括图片,标题等等。
EmptyDataSetDelegate:定义了很多UI操作的监听回调,比如点击。这么多代码就可以实现功能了,接下里看看具体实现的原理。

 //MARK: - Public Property
    public var emptyDataSetSource: EmptyDataSetSource? {
        get {
            let container = objc_getAssociatedObject(self, &kEmptyDataSetSource) as? WeakObjectContainer
            return container?.weakObject as? EmptyDataSetSource
        }
        set {
            if newValue == nil {
                self.invalidate()
            }

            objc_setAssociatedObject(self, &kEmptyDataSetSource, WeakObjectContainer(with: newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
            UIScrollView.swizzleReloadData
            if self is UITableView {
                UIScrollView.swizzleEndUpdates
            }
        }
    }
    
    public var emptyDataSetDelegate: EmptyDataSetDelegate? {
        get {
            let container = objc_getAssociatedObject(self, &kEmptyDataSetDelegate) as? WeakObjectContainer
            return container?.weakObject as? EmptyDataSetDelegate
        }
        set {
            if newValue == nil {
                self.invalidate()
            }
            objc_setAssociatedObject(self, &kEmptyDataSetDelegate, WeakObjectContainer(with: newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }
    

UIScrollView.swizzleReloadData方法

   private static let swizzleReloadData: () = {
        let tableViewOriginalSelector = #selector(UITableView.reloadData)
        let tableViewSwizzledSelector = #selector(UIScrollView.tableViewSwizzledReloadData)
        
        swizzleMethod(for: UITableView.self, originalSelector: tableViewOriginalSelector, swizzledSelector: tableViewSwizzledSelector)
        
        let collectionViewOriginalSelector = #selector(UICollectionView.reloadData)
        let collectionViewSwizzledSelector = #selector(UIScrollView.collectionViewSwizzledReloadData)
        
        swizzleMethod(for: UICollectionView.self, originalSelector: collectionViewOriginalSelector, swizzledSelector: collectionViewSwizzledSelector)
    }()

在这个方法里利用runtime的交换方法劫持了系统的UITableView.reloadData方法,这样当你调用UITableView.reloadData方法时实际上调用的是UIScrollView.tableViewSwizzledReloadData方法

//MARK: - Method Swizzling
   @objc private func tableViewSwizzledReloadData() {
       tableViewSwizzledReloadData()
       reloadEmptyDataSet()
   }

在这个方法里首先调用本身,实际上调用的是UITableView.reloadData,因为互相交换了。然后调用reloadEmptyDataSet()方法

//MARK: - Reload APIs (Public)
    public func reloadEmptyDataSet() {
        //只要emptyDataSetSource和configureEmptyDataSetView都为nil则不做任何处理
        guard (emptyDataSetSource != nil || configureEmptyDataSetView != nil) else {
            return
        }
        //允许显示并且数据源的个数为0,也就是所有区的行数都是0,则显示展位图,或者shouldBeForcedToDisplay==true,强行显示
        if (shouldDisplay && itemsCount == 0) || shouldBeForcedToDisplay {
            // Notifies that the empty dataset view will appear
            willAppear()
            
            if let view = emptyDataSetView {
                
                // Configure empty dataset fade in display
                view.fadeInOnDisplay = shouldFadeIn
                
                if view.superview == nil {
                    // Send the view all the way to the back, in case a header and/or footer is present, as well as for sectionHeaders or any other content
                    if (self is UITableView) || (self is UICollectionView) || (subviews.count > 1) {
                        insertSubview(view, at: 0)
                    } else {
                        addSubview(view)
                    }
                }
                
                // Removing view resetting the view and its constraints it very important to guarantee a good state
                // If a non-nil custom view is available, let's configure it instead
                view.prepareForReuse()
                
                if let customView = self.customView {
                    view.customView = customView
                } else {
                    // Get the data from the data source
                    
                    let renderingMode: UIImage.RenderingMode = imageTintColor != nil ? .alwaysTemplate : .alwaysOriginal
                    
                    view.verticalSpace = verticalSpace
                    
                    // Configure Image
                    if let image = image {
                        view.imageView.image = image.withRenderingMode(renderingMode)
                        if let imageTintColor = imageTintColor {
                            view.imageView.tintColor = imageTintColor
                        }
                    }
                    
                    // Configure title label
                    if let titleLabelString = titleLabelString {
                        view.titleLabel.attributedText = titleLabelString
                    }
                    
                    // Configure detail label
                    if let detailLabelString = detailLabelString {
                        view.detailLabel.attributedText = detailLabelString
                    }
                    
                    // Configure button
                    if let buttonImage = buttonImage(for: .normal) {
                        view.button.setImage(buttonImage, for: .normal)
                        view.button.setImage(self.buttonImage(for: .highlighted), for: .highlighted)
                    } else if let buttonTitle = buttonTitle(for: .normal) {
                        view.button.setAttributedTitle(buttonTitle, for: .normal)
                        view.button.setAttributedTitle(self.buttonTitle(for: .highlighted), for: .highlighted)
                        view.button.setBackgroundImage(self.buttonBackgroundImage(for: .normal), for: .normal)
                        view.button.setBackgroundImage(self.buttonBackgroundImage(for: .highlighted), for: .highlighted)
                    }
                }
                
                // Configure offset
                view.verticalOffset = verticalOffset
                
                // Configure the empty dataset view
                view.backgroundColor = dataSetBackgroundColor
                view.isHidden = false
                view.clipsToBounds = true
                
                // Configure empty dataset userInteraction permission
                view.isUserInteractionEnabled = isTouchAllowed
                
                // Configure scroll permission
                self.isScrollEnabled = isScrollAllowed
                
                // Configure image view animation
                if self.isImageViewAnimateAllowed {
                    if let animation = imageAnimation {
                        view.imageView.layer.add(animation, forKey: nil)
                    }
                } else {
                    view.imageView.layer.removeAllAnimations()
                }
                
                if let config = configureEmptyDataSetView {
                    config(view)
                }
                
                view.setupConstraints()
                view.layoutIfNeeded()
            }
            
            // Notifies that the empty dataset view did appear
            didAppear()
        } else if isEmptyDataSetVisible {
            invalidate()
        }
    }

通过这个方法进行判断,如果确实没有数据时怎会配置空白view,显示在屏幕上。这个库整体代码量不多,但很灵活,可以根据不同的需求配置不同的空白页,它的设计思想很喜欢。