一、效果图:(文末有彩蛋,阅完欢迎留下评论一起交流学习)
二、设计思路分析:
1.UITableViw、UICollection之所以好用,是因为采用cell的复用机制,即把当前未在屏幕上显示的cell回收,待下一个出现在屏幕上时,使用其进行复用,这种设计即可以达到以最少的cell呈现大量的cell效果。
复用的机制这里采用了对象池模式,对于对象池模式可以查阅我的另一编文章,这里不在赘述了,不过那编文章是objc版的,在本案例中,我又写了一个swift版的,思路一致,只是语言的陈述不一样布局,欢迎各位看官批评指正。
2.复用的时机计算:
首先需要获得每一个cell(或者称之为item)的宽度(针对横向滚动)高度(针对纵身滚动),这个不难获取。因为在自定义cell的时候每个cell的size是硬编码写死的,本案例中item的实现横向滚动,宽度为120point,其中每个item之间的间距为20point。
获得cell的size之后,需要与屏幕的宽度(因为本案例实现横向滚动,下同)进行对比,计算出屏幕最大的显示个数:
override func layoutSubviews() { houseGroupScrollView.frame = CGRect.init(x: 0, y: 0, width: self.frame.width, height: self.frame.height) nPageMaxCount = Int(ceil(self.frame.width / CGFloat(140))) //页面最大个数为 单元格宽度/单个item宽度 //print("45-----------:\(nPageMaxCount)") }
其次计算回收与复用时机。对于这个点我们需要重点利用UIScrollView的contentOffset,通过观察这个属性的变化来计算回收时机。经向左滚动为例:
UIScrollView向左滑动时当contentOffsetX 大于一个(cell的宽度 + 间距) 时就需要将最左边的一个cell进行回收(因为此时这个cell已经完全不可见了)最右边一个也即将显示,而显示并需要再次创建直接将回收的这个再次显示在最右边同时修改它上面的的控件显示的数据即可。
这块的核心代码如下:(下面会给出全部代码,hold住)
if offsetX > 0 && deltaOfOffsetX > 0 { //单纯向左滑 let nIdx = houseGroupScrollView.subviews.count if nLastTag >= (houseGroupData?.houseGroupItemList.count)! && houseGroupScrollView.viewWithTag(nLastTag) != nil{ return } if(offsetX > 20 && offsetX <= 140){ //特殊情况,左边隐藏半个,右边显示半个,此时也需要创建一个itemView出来 if nIdx > nPageMaxCount { return } let itemView : HouseGroupItemView = mPool.getObjFromPool() let posX : CGFloat = (itemView.width() + CGFloat(20)) * CGFloat(nIdx) + CGFloat(20) itemView.setX(x: posX ) itemView.tag = nIdx + 1 itemView.setTitle(title: (houseGroupData?.houseGroupItemList[nIdx].title)!) itemView.setAbstract(abstract: (houseGroupData?.houseGroupItemList[nIdx].abstract!)!) houseGroupScrollView.addSubview(itemView) nLastTag = nIdx + 1 nHasAddCount += 1 } let recycIdx : Int = Int(offsetX / 140) var recycObj : HouseGroupItemView? = houseGroupScrollView.viewWithTag(recycIdx) as? HouseGroupItemView let lastItemView : HouseGroupItemView? = houseGroupScrollView.viewWithTag(recycIdx + nPageMaxCount) as? HouseGroupItemView //print("124------------:recycIdx:\(recycIdx) pageCount:\(String(describing: lastItemView)))") if recycObj != nil && lastItemView != nil{ mPool.putObj2Pool(obj: &recycObj) recycObj?.removeFromSuperview() let itemView : HouseGroupItemView = mPool.getObjFromPool() itemView.setX(x: lastItemView!.x() + 140) itemView.tag = recycIdx + nPageMaxCount + 1 //print("138-----------:\(recycIdx + nPageMaxCount + 1) titles:\(houseGroupData?.houseGroupItemList.count)") itemView.setTitle(title: (houseGroupData?.houseGroupItemList[(lastItemView?.tag)!].title)!) //测试之用 注释之后,可以查看回收的是那一个 itemView.setAbstract(abstract: (houseGroupData?.houseGroupItemList[(lastItemView?.tag)!].abstract!)!) houseGroupScrollView.addSubview(itemView) nLastTag = recycIdx + nPageMaxCount + 1 nHasAddCount += 1 } fTotalOffsetX = offsetX }
有了左边的方向,相信右边就不难了。
三、关于每个cell的tag
为每个cell设计tag是回收与复用需要注意的地方,因为每个cell上还需要显示数据列表中实体的信息,这就牵涉到
tag的设计不能太过奇葩,不然会加大计算量。本案例中的tag设计依次递增/递减。
四、全部代码
import Foundation import UIKit import SnapKit /// 首页房子分组单元格视图--本单元格使用UIScrollView代替横向的UItableView /// 这个单元格只放一个滚动容器,itemView抽出来 class HomeHouseGroupCell: BaseTabViewCell { lazy var houseGroupScrollView : UIScrollView = UIScrollView.init() var houseGroupData : HomeHouseGroupModel? lazy var mPool : HouseGroupItemViewPool = HouseGroupItemViewPool.init() var nPageMaxCount : Int = 0 var fTotalOffsetX : CGFloat = 0 //记录已经滑动的距离 var nLastTag :Int = -1 var nHasAddCount : Int = -1 var nLastRightRecycIdx = -1 override func initItemView() { houseGroupScrollView.backgroundColor = UIColor.green houseGroupScrollView.alwaysBounceHorizontal = true houseGroupScrollView.showsVerticalScrollIndicator = false houseGroupScrollView.showsHorizontalScrollIndicator = false self.addSubview(houseGroupScrollView) houseGroupScrollView.addObserver(self, forKeyPath: "contentOffset", options: [.new, .old], context: nil) } override func layoutSubviews() { houseGroupScrollView.frame = CGRect.init(x: 0, y: 0, width: self.frame.width, height: self.frame.height) nPageMaxCount = Int(ceil(self.frame.width / CGFloat(140))) //页面最大个数为 单元格宽度/单个item宽度 //print("45-----------:\(nPageMaxCount)") } override func bindData(data: BaseModel?) { if data != nil && houseGroupData != data{ houseGroupData = data as? HomeHouseGroupModel houseGroupScrollView.contentSize = CGSize.init(width: (houseGroupData?.houseGroupItemList.count)! * 140 + 20, height: Int(self.frame.height)) for (idx,item) in (houseGroupData?.houseGroupItemList.enumerated())!{ if CGFloat(idx * 140 + 20) > self.frame.width{ //对于走出屏幕范围的对象暂时不直接创建,而是是通过复用对象来达到显示目的 break } let itemView : HouseGroupItemView = mPool.getObjFromPool() let posX : CGFloat = idx == 0 ? 20 : (itemView.width() + CGFloat(20)) * CGFloat(idx) + CGFloat(20) itemView.setX(x: posX ) itemView.tag = idx + 1 itemView.setTitle(title: item.title!) itemView.setAbstract(abstract: item.abstract!) houseGroupScrollView.addSubview(itemView) nHasAddCount += 1 } } } override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { if keyPath == "contentOffset"{ let offset : CGPoint = change![NSKeyValueChangeKey.newKey] as! CGPoint let offsetX : CGFloat = offset.x let oldOffset : CGPoint = change![NSKeyValueChangeKey.oldKey] as! CGPoint let oldOffsetX : CGFloat = oldOffset.x let deltaOfOffsetX : CGFloat = offsetX - oldOffsetX //print("91------------offsetX :\(offsetX) deltaX : \(deltaOfOffsetX) recycIdx : \(Int(offsetX / 140))") if offsetX > 0 && deltaOfOffsetX > 0 { //单纯向左滑 let nIdx = houseGroupScrollView.subviews.count if nLastTag >= (houseGroupData?.houseGroupItemList.count)! && houseGroupScrollView.viewWithTag(nLastTag) != nil{ return } if(offsetX > 20 && offsetX <= 140){ //特殊情况,左边隐藏半个,右边显示半个,此时也需要创建一个itemView出来 if nIdx > nPageMaxCount { return } let itemView : HouseGroupItemView = mPool.getObjFromPool() let posX : CGFloat = (itemView.width() + CGFloat(20)) * CGFloat(nIdx) + CGFloat(20) itemView.setX(x: posX ) itemView.tag = nIdx + 1 itemView.setTitle(title: (houseGroupData?.houseGroupItemList[nIdx].title)!) itemView.setAbstract(abstract: (houseGroupData?.houseGroupItemList[nIdx].abstract!)!) houseGroupScrollView.addSubview(itemView) nLastTag = nIdx + 1 nHasAddCount += 1 } let recycIdx : Int = Int(offsetX / 140) var recycObj : HouseGroupItemView? = houseGroupScrollView.viewWithTag(recycIdx) as? HouseGroupItemView let lastItemView : HouseGroupItemView? = houseGroupScrollView.viewWithTag(recycIdx + nPageMaxCount) as? HouseGroupItemView //print("124------------:recycIdx:\(recycIdx) pageCount:\(String(describing: lastItemView)))") if recycObj != nil && lastItemView != nil{ mPool.putObj2Pool(obj: &recycObj) recycObj?.removeFromSuperview() let itemView : HouseGroupItemView = mPool.getObjFromPool() itemView.setX(x: lastItemView!.x() + 140) itemView.tag = recycIdx + nPageMaxCount + 1 //print("138-----------:\(recycIdx + nPageMaxCount + 1) titles:\(houseGroupData?.houseGroupItemList.count)") itemView.setTitle(title: (houseGroupData?.houseGroupItemList[(lastItemView?.tag)!].title)!) //测试之用 注释之后,可以查看回收的是那一个 itemView.setAbstract(abstract: (houseGroupData?.houseGroupItemList[(lastItemView?.tag)!].abstract!)!) houseGroupScrollView.addSubview(itemView) nLastTag = recycIdx + nPageMaxCount + 1 nHasAddCount += 1 } fTotalOffsetX = offsetX } if offsetX > 0 && deltaOfOffsetX < 0 { //向右滑,此时左边有一部分的内容超出了滚动列表可见页面 let rightOffsetX : CGFloat = fTotalOffsetX + 140 - offsetX //let recycIdx : Int = Int(offsetX / 140) //print("147------------total: \(fTotalOffsetX) offsetX:\(offsetX)") if rightOffsetX > 0 && rightOffsetX < 20{ //特殊情况,左边隐藏半个,右边显示半个,此时也需要创建一个itemView出来 let nIdx = houseGroupScrollView.subviews.count if nIdx > (nPageMaxCount + 1) || nIdx >= (houseGroupData?.houseGroupItemList.count)!{ return } let firstItemView : HouseGroupItemView = houseGroupScrollView.viewWithTag(nLastTag - nPageMaxCount) as! HouseGroupItemView let itemView : HouseGroupItemView = mPool.getObjFromPool() itemView.setX(x: firstItemView.x() - 140) itemView.tag = firstItemView.tag - 1 itemView.setTitle(title: (houseGroupData?.houseGroupItemList[firstItemView.tag - 2].title)!) itemView.setAbstract(abstract: (houseGroupData?.houseGroupItemList[firstItemView.tag - 2].abstract!)!) houseGroupScrollView.addSubview(itemView) } if rightOffsetX >= 120 { //达到回收对象的条件 let rightIdx : Int = Int(rightOffsetX / 140) if rightIdx < 1 || rightIdx == nLastRightRecycIdx{ return } let recycIdx : Int = nLastTag //print("202-------------nLastTag: \(nLastTag) rightIdx:\(rightIdx) availableList.count:\(mPool.availableList.count)") var recycObj : HouseGroupItemView? = houseGroupScrollView.viewWithTag(recycIdx) as? HouseGroupItemView print("181-----------recycIdx: \(recycIdx): recycbj: \(String(describing: recycObj?.labelTitle.text))") let firstItemView : HouseGroupItemView? = houseGroupScrollView.viewWithTag(recycIdx - nPageMaxCount) as? HouseGroupItemView //print("207-------------:\((recycObj?.x())! - offsetX)") if recycObj != nil && firstItemView != nil{ if (firstItemView?.x())! <= 100 { return } print("208-----------firstTag: \(String(describing: firstItemView?.tag)): recycbj: \(String(describing: recycObj?.labelTitle.text))") nLastTag = (recycObj?.tag)! - 1 nLastRightRecycIdx = rightIdx mPool.putObj2Pool(obj: &recycObj) recycObj?.removeFromSuperview() let itemView : HouseGroupItemView = mPool.getObjFromPool() itemView.setX(x: (firstItemView?.x())! - CGFloat(140)) itemView.tag = (firstItemView?.tag)! - 1 itemView.setTitle(title: (houseGroupData?.houseGroupItemList[(firstItemView?.tag)! - 2].title)!) //测试之用 注释之后,可以查看回收的是那一个 itemView.setAbstract(abstract: (houseGroupData?.houseGroupItemList[(firstItemView?.tag)! - 2].abstract!)!) //print("186-----------firstItemView:\(String(describing: firstItemView?.labelTitle.text)) recycObj:\(String(describing: recycObj?.labelTitle.text))") houseGroupScrollView.addSubview(itemView) } } } } } deinit { houseGroupScrollView.removeObserver(self, forKeyPath: "contentOffset") } }
顺带将BaseTableCell源码也奉上:
import Foundation import UIKit /// UItableView 中的单元格基类 所有的单元格均需要重载本灰 class BaseTabViewCell: UITableViewCell { var label:UILabel? override init(style: UITableViewCellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) initItemView() } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 子类需要重载本接口 func initItemView() { label = UILabel(frame:CGRect(x : 10.0 , y : self.center.y, width : 80, height:30)) label?.text = "韦小宝" label?.font = UIFont.systemFont(ofSize: 14) self.addSubview(label!) } /// 将数据模型与单元格绑定起来,子类需要重载本接口 /// /// - Parameter data: <#data description#> func bindData(data:BaseModel?){ } /// 回收资源接口,子类必要时重写 func recycRes(){ } }
使用的时候直接添加到UIScrollView即可
彩蛋:对象池模式的Swift版:
关于这个模式的思路分析,此处不再分析, 我的另一编文章有分析,劳驾移步评阅
池类基类:
import Foundation /// 规范对象池接口--使用关联对象类型 protocol ObjPoolListener { associatedtype objType func create() -> objType //创建对象 func validate(obj : objType?) -> Bool //验证对象的有效性 func recycObj(var obj : inout objType?) //回收对象 func getObjFromPool() -> objType //从对象中取对象 func putObj2Pool(var obj: inout objType?) //将对象放回对象池 } class BaseObjPool<T>: NSObject,ObjPoolListener { typealias objType = T //将对象进行关联 lazy var inUserList : NSMutableArray = NSMutableArray.init() lazy var availableList : NSMutableArray = NSMutableArray.init() override init() { } /***********************implements protocol ******************/ func getObjFromPool() -> T { var t :T? if self.availableList.count > 0 { for obj in self.availableList{ t = (obj as! T) self.availableList.remove(t!) self.inUserList.add(t!) break; } }else{ t = create() as? T self.inUserList.add(t!) } return t! } func putObj2Pool(var obj: inout T?) { guard obj != nil else{ return } self.inUserList.remove(obj!) if validate(obj: obj){ self.availableList.add(obj!) }else{ recycObj(obj: &obj) } } func recycObj(var obj: inout T?) { obj = nil } func validate(obj: T?) -> Bool { return obj != nil } func create() -> T { return NSObject.init() as! T } }
池类需要生成的对象类:
import Foundation import UIKit import SnapKit /// 首页中间滚动列表--房子分组的item视图 class HouseGroupItemView: UIView { lazy var labelTitle : UILabel = UILabel.init() lazy var labelAbstract : UILabel = UILabel.init() lazy var imgHouseThumab : UIImageView = UIImageView.init() override init(frame: CGRect) { super.init(frame: frame) iniView() } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } func iniView() { self.backgroundColor = UIColor.orange labelTitle.font = UIFont.systemFont(ofSize: 16) labelTitle.text = "Title" labelTitle.textAlignment = .center labelTitle.backgroundColor = UIColor.red self.addSubview(labelTitle) labelTitle.translatesAutoresizingMaskIntoConstraints = false labelTitle.snp.makeConstraints(){(make) -> Void in make.top.equalTo(self).offset(15) make.leading.equalTo(self).offset(10) make.trailing.equalTo(self).offset(-10) make.height.equalTo(30) } labelAbstract.font = UIFont.systemFont(ofSize: 16) labelAbstract.text = "Abstract" labelAbstract.textAlignment = .center labelAbstract.backgroundColor = UIColor.gray self.addSubview(labelAbstract) labelAbstract.translatesAutoresizingMaskIntoConstraints = false labelAbstract.snp.makeConstraints(){(make) -> Void in make.top.equalTo(labelTitle.snp.bottom).offset(10) make.leading.equalTo(self).offset(10) make.trailing.equalTo(self).offset(-10) make.height.equalTo(25) } } func setTitle(title : String) { labelTitle.text = title } func setAbstract(abstract : String) { labelAbstract.text = abstract } // func setHouseImgUrl(imgUrl : String) { // // imgHouseThumab = // } }
具体的池类,用来生成上面提到的类实例对象,同时用来管理它的生命周期:
import Foundation import UIKit /// 首页房子分组滚动列表---这个滚动列表使用对象池实现 class HouseGroupItemViewPool: BaseObjPool<HouseGroupItemView> { override init() { } override func create() -> HouseGroupItemView { let itemView : HouseGroupItemView = HouseGroupItemView.init(frame: CGRect.init(x: 0, y: 15, width: 120, height: 150)) return itemView } }