利用scrollView实现瀑布流 Swift3.0


Origin blog

以前在网上看到过(自己也实现过)使用oc写的基于scrollView实现的瀑布流,现在自己的项目都由swift编写了,所以趁有时间,把以前的oc项目转一下swift好了。

1.新建一个 waterflow 继承至 UIScrollView,创建一个 WaterflowViewCell继承至 UIView
WaterflowViewCell中,需创建 identifier 属性,用于 cell 复用

class WaterflowViewCell: UIView {
    var identifier: String?
}

在waterflow中...
a).声明一个可变数组cellFrames,用于存放所有 cell 的 frame;
b).声明一个可变字典displayingCells,用于存放正在显示的 cell, 字典的 key 为 index,value 为 cell 对象;
c).声明一个可变的集合reusableCells,用于存放所有离开屏幕的 cell。
因为不需要公开,所有设置为私有

fileprivate lazy var cellFrames = NSMutableArray()
fileprivate lazy var displayingCells = NSMutableDictionary()
fileprivate lazy var reusableCells = NSMutableSet()

2.创建一个遮罩层,用于当用户点击cell 之后,展示点击效果

fileprivate lazy var matteView: UIView = {
   var view = UIView()
   view.backgroundColor = UIColor.blackColor().colorWithAlphaComponent(0.1)
   return view
}()

3.可以考虑模仿 tableView,设置相应的数据源方法和代理方法
数据源方法和代理方法此处分别设置了三个,考虑的项目的实际情况,暂时设置这几个,或者考虑利用 collection 来实现瀑布流,下篇博客或者考虑利用 collection 来实现瀑布流,所有的动作可以使用原生提供的 API 进行开发。

a).这里的WaterflowMarginType是相应的间隔类型的枚举类,为什么会使用@objc?因为此枚举在代理方法中被用作参数传递了,所有需要在枚举开头加上@objc,至于数据源方法和代理方法也加上了@objc,目的在于设置协议的 optional 属性。
b).数据源中的第三个方法numberOfColumnsInWaterflow要求返回所要展示的 cell 的列数,当然不实现此方法的话默认会展示三列。
c).代理方法中:
heightAtIndex方法要求返回 cell 的高度,默认是44;
didSelectAtIndex方法是 cell 的点击回调;
marginForType方法返回 cell 间间隙的宽度,默认是1。

@objc enum WaterflowMarginType: Int {
    
    case top
    case bottom
    case left
    case right
    case column
    case row
}

@objc protocol WaterflowDataSource: NSObjectProtocol {
    
    func numberOfCellsInWaterflow(waterflow: WaterflowView) -> Int
    
    func waterflow(waterflow: WaterflowView, cellAtIndex index: Int) -> WaterflowViewCell
    
    @objc optional func numberOfColumnsInWaterflow(waterflow: WaterflowView) -> Int
}

@objc protocol WaterflowDelegate: NSObjectProtocol {
    @objc optional func waterflow(waterflow: WaterflowView, heightAtIndex index: Int) -> CGFloat
    
    @objc optional func waterflow(waterflow: WaterflowView, didSelectAtIndex index: Int)
    
    @objc optional func waterflow(waterflow: WaterflowView, marginForType type: WaterflowMarginType) -> CGFloat
}

4.以下是waterflowView 类的代码部分,为了代码的可读性和整洁性,其余 public 方法和 private 方法将在 waterflowView 的类扩展中实现

   对于 cell 的穿件以及属性的计算将会在`willMoveToSuperview`和`layoutSubviews`中完成,`willMoveToSuperview`什么时候会被触发?

在此拓展知识,用于笔记查阅也用于提醒铭记

-(id)initWithFrame:(CGRect)frame - UIView的指定初始化方法; 总是发送给UIView去初始化, 除非是从一个nib文件中加载的;
-(id)initWithCoder:(NSCoder *)coder - 从nib文件中加载的时候发送此消息给UIView;
-(void)awakeFromNib - 在所有的nib中的对象初始化和连接后将发送此消息; 只适用于从nib加载对象; 如要重写,其中还必须调用父类的awakeFromNib;
-(void)willMoveToSuperview:(UIView *)newSuperview - 在一个子视图将要被添加到另一个视图的时候发送此消息;
-(void)willMoveToWindow:(UIWindow *)newWindow - 在一个视图(或者它的超视图)将要被添加到window的时候发送;
-(void)didMoveToSuperview - 把一个视图插入到视图层级之后发送此消息;
-(void)didMoveToWindow - 当视图获得它的window属性集的时候发送此消息.
class WaterflowView: UIScrollView {

    // delegate
    var dataSource: WaterflowDataSource?
    var wfDelegate: WaterflowDelegate?
    
    fileprivate lazy var cellFrames = NSMutableArray()
    fileprivate lazy var displayingCells = NSMutableDictionary()
    fileprivate lazy var reusableCells = NSMutableSet()
    
    //  默认值
    fileprivate let WaterflowDefaultCellH: CGFloat = 44
    fileprivate let WaterflowDefaultMargin: CGFloat = 1
    fileprivate let WaterflowDefaultNumberOfColumns: Int = 3
    
    // 遮罩层
    fileprivate lazy var matteView: UIView = {
        var view = UIView()
        view.backgroundColor = UIColor.black.withAlphaComponent(0.1)
        return view
    }()
    
    // 用于记录 cell
    fileprivate var cTupe: (NSNumber?, WaterflowViewCell?)
    
    override func willMove(toSuperview newSuperview: UIView?) {
        reloadData()
    }
}

5. waterflowView 中 private 方法的类扩展
a).isInScreen方法用于判断 cell 的 frame 是否在屏幕中,这里只考虑纵向。
b).marginForType获取 cell 间的间隙大小,<font color=purple>这里为什么不用respondsToSelector判断代理方法是否被实现?主要原因在于wfDelegate或者是waterflow后面的?,如果wfDelegate没有被代理或者waterflow没有被实现,则会调用??后面的WaterflowDefaultMargin,有点类似于三目运算有没有?其他代理方法也是这个原理。</font>

// MARK: - private
extension WaterflowView {
    fileprivate func isInScreen(frame: CGRect) -> Bool {
        return (frame.maxY > contentOffset.y) &&
            (frame.maxY < contentOffset.y + bounds.height)
    }
    
    fileprivate func marginForType(type: WaterflowMarginType) -> CGFloat {

        return wfDelegate?.waterflow?(waterflow: self, marginForType: type) ?? WaterflowDefaultMargin
    }
    
    fileprivate func numberOfColumns() -> Int {
        return dataSource?.numberOfColumnsInWaterflow?(waterflow: self) ?? WaterflowDefaultNumberOfColumns
    }
    
    fileprivate func heightAtIndex(index: Int) -> CGFloat {

        return wfDelegate?.waterflow?(waterflow: self, heightAtIndex: index) ?? WaterflowDefaultCellH
    }
}

6. waterflowView 中 public 方法的类扩展

cellWidth方法可以获取到 cell 的宽度

func cellWidth() -> CGFloat {
   let columns = numberOfColumns()
   let leftM = marginForType(type: .left)
   let rightM = marginForType(type: .right)
   let columnM = marginForType(type: .column)
   
   return (bounds.width - leftM - rightM - (CGFloat(columns) - 1) * columnM) / CGFloat(columns)
}

reloadData方法代码比较长,具体看注释就可以了

func reloadData() {
   /*!
    displayingCells为当前屏幕显示的 cell,是一个字典,
    因此通过 allValues 可获取到字典中所有的 cell 对象,
    forEach方法属于 for 循环的特殊用法(在forEach闭包中,
    $0表示 字典中的 value,当然也可用闭包通用形式中 {value in method} 来编写),
    这里需要移除所有的 cell。
    */
   displayingCells.allValues.forEach {
       ($0 as AnyObject).removeFromSuperview()
   }
   
   // 清空数组、字典、集合
   displayingCells.removeAllObjects()
   cellFrames.removeAllObjects()
   reusableCells.removeAllObjects()
   
   // 获取 cell 的总数
   let cells = dataSource?.numberOfCellsInWaterflow(waterflow: self)
   
   // waterflow 的列数
   let columns = numberOfColumns()
   
   // cell 间的间隙
   let topM = marginForType(type: .top)
   let bottomM = marginForType(type: .bottom)
   let leftM = marginForType(type: .left)
   let columnM = marginForType(type: .column)
   let rowM = marginForType(type: .row)
   
   let cellW = cellWidth()
   
   // 创建一个空的数组,大小为columns
   var maxYOfColumns: Array<CGFloat> = Array(repeating: 0.0, count: columns)
   // 循环初始化所有列的最大 y 值,瀑布流中每一行的 cell 所在位置是上一行中 y 值最小的 cell
   for i in 0..<columns {
       maxYOfColumns[i] = 0.0
   }
   
   // cells == nil return
   guard let _cells = cells else {
       return
   }
   
   for i in 0..<_cells {
       // 找出 y 值最小的 cell
       var cellColumn = 0
       var maxYOfCellColumn = maxYOfColumns[cellColumn]
       for j in 1..<columns {
           if maxYOfColumns[j] < maxYOfCellColumn {
               cellColumn = j
               maxYOfCellColumn = maxYOfColumns[j]
           }
       }
       
       let cellH = heightAtIndex(index: i)
       
       let cellX: CGFloat = leftM + CGFloat(cellColumn) * (cellW + columnM)
       var cellY: CGFloat = 0.0
       
       if maxYOfCellColumn == 0.0 {
           cellY = topM
       } else {
           cellY = maxYOfCellColumn + rowM
       }
       
       // 把 cell 的 frame 添加到 cellFrame 数组中,并记录当前列的最大 y 值
       let cellFrame = CGRect(x: cellX, y: cellY, width: cellW, height: cellH)
       cellFrames.add(NSValue(cgRect: cellFrame))
       maxYOfColumns[cellColumn] = cellFrame.maxY
   }
   
   var contentH = maxYOfColumns[0]
   for j in 0..<columns {
       if maxYOfColumns[j] > contentH {
           contentH = maxYOfColumns[j]
       }
   }
   
   contentH += bottomM
   // 设置 scrollView 的 contentSize
   contentSize = CGSize(width: 0, height: contentH)
}

layoutSubviews每次滚动屏幕时都会触发

override func layoutSubviews() {
   super.layoutSubviews()
   
   // 索要对应位置的 cell
   let cells = cellFrames.count
   for i in 0..<cells {
       // 取出 i index 中的 frame
       let cellFrame = (cellFrames[i] as AnyObject).cgRectValue
       // 优先从字典中取出 cell
       var cell: WaterflowViewCell? = displayingCells[i] as? WaterflowViewCell
       
       // 判断对应的 frame 在不在屏幕上
       if isInScreen(frame: cellFrame!) {
           
           // 如果 frame 在屏幕上,但是 cell 并没有被创建,
           // 则创建 cell,并且存放进 displayingCells字典中
           guard cell != nil else {
               cell = dataSource?.waterflow(waterflow: self, cellAtIndex: i)
               cell!.frame = cellFrame!
               addSubview(cell!)
               displayingCells[i] = cell
               
               continue
           }
           
           continue
           
       } else {
           
           // 如果不在,则把 cell 从当前屏幕中移除,并添加到缓存中
           guard let cell = cell else {
               continue
           }
           
           cell.removeFromSuperview()
           displayingCells.removeObject(forKey: i)
           reusableCells.add(cell)
       }
   }
}

dequeueReusableCellWithIdentifiercell 重用,更加 cell 的 id 查找缓存中是否有已创建的 cell,如果有则获取这个 cell 返回并从缓存中移除。

func dequeueReusableCellWithIdentifier(identifier: String) -> AnyObject? {
   var reusableCell: WaterflowViewCell?
   for cell in reusableCells {
       let cell = cell as! WaterflowViewCell
       if cell.identifier == identifier {
           reusableCell = cell
           break
       }
   }
   
   if reusableCell != nil {
       reusableCells.remove(reusableCell!)
   }
   return reusableCell
}

7. waterflowView 中 事件 方法的类扩展
通过touch方法实现事件的点击,在开始点击和结束点击时,分别添加遮罩和移除遮罩,当用户手指移动时,判断当前手指是否还在对应的 cell 中,如果不在则移除遮罩
但是这里实现还是有一点点小问题,但倒不影响使用,如果有好的思路到时再补充好了...

// MARK: - action
extension WaterflowView {
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard wfDelegate != nil else {
            return
        }
        
        let cellTupe = getCurrentTouchView(touches: touches)
        let cell = cellTupe.1
        
        guard let _cell = cell else {
            return
        }
        
        cTupe = cellTupe
        
        // 添加遮罩
        matteView.frame = _cell.bounds
        _cell.addSubview(matteView)
        _cell.bringSubview(toFront: matteView)
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let wfDelegate = wfDelegate else {
            return
        }
        
        let cellTupe = getCurrentTouchView(touches: touches)
        let selectIdx = cellTupe.0
        
        if selectIdx == cTupe.0 {
            
            let cell = cellTupe.1
            
            // 移除遮罩
            let matteV = cell?.subviews.last
            matteV?.removeFromSuperview()
            
            if (selectIdx != nil) {
                wfDelegate.waterflow?(waterflow: self, didSelectAtIndex: selectIdx!.intValue)
            }
        }
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        let cellTupe = getCurrentTouchView(touches: touches)
        // 如果不在点击层 移除遮罩
        if cTupe.0 != cellTupe.0 {
            let matteV = cTupe.1!.subviews.last
            matteV?.removeFromSuperview()
        } else {
            // 如果在点击层且没有遮罩,添加遮罩
            if cellTupe.1!.subviews.last != matteView {
                matteView.frame = cellTupe.1!.bounds
                cellTupe.1!.addSubview(matteView)
                cellTupe.1!.bringSubview(toFront: matteView)
            }
        }
    }
    
    private func getCurrentTouchView(touches: Set<UITouch>) -> (NSNumber?, WaterflowViewCell?) {
        let touch: UITouch = (touches as NSSet).anyObject() as! UITouch
        let point = touch.location(in: self)
        
        var selectIdx: NSNumber?
        var selectCell: WaterflowViewCell?
        
        // 获取点击层对应的 cell
        for (key, value) in displayingCells {
            let cell = value as! WaterflowViewCell
            if cell.frame.contains(point) {
                selectIdx = (key as! NSNumber)
                selectCell = cell
                break
            }
        }
        return (selectIdx, selectCell)
    }
    
}

文章如若有错误或者误导的地方,还请原谅,如果方便,欢迎留言!

Code demo · github

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

推荐阅读更多精彩内容

  • 转至元数据结尾创建: 董潇伟,最新修改于: 十二月 23, 2016 转至元数据起始第一章:isa和Class一....
    40c0490e5268阅读 1,690评论 0 9
  • *面试心声:其实这些题本人都没怎么背,但是在上海 两周半 面了大约10家 收到差不多3个offer,总结起来就是把...
    Dove_iOS阅读 27,135评论 30 470
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,066评论 4 62
  • 1.自定义控件 a.继承某个控件 b.重写initWithFrame方法可以设置一些它的属性 c.在layouts...
    圍繞的城阅读 3,371评论 2 4
  • 面对纷繁复杂的世界,我们似乎总是会忘了自己的初心。 总是把自己困在角落,总觉得这样做似乎是最好的结局。
    木以寒阅读 126评论 0 0