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,显示在屏幕上。这个库整体代码量不多,但很灵活,可以根据不同的需求配置不同的空白页,它的设计思想很喜欢。

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