从头开始写一款开源app上线,相互学习(二)

在上一篇文章从头开始写一款开源app上线,相互学习(一),我们已经将项目的框架及第三方库,还有网络层都搭建好了,接下来的工作就是界面搭建
先看看新闻列表效果图:

新闻列表效果图

界面搭建

新闻页分析

1.左右滚动的分类条
2.上下滚动的列表
3.左右滚动切换不同分类的列表
4.分类条与列表之间的联动效果


界面分析

控件布局分析

1.分类条使用一个View分离
2.列表区支持左右滚动,使用一个collectionView于分类条下面,collectionView的cell对应一个列表
3.为了使每个列表加载完并缓存起来,使用containerView将每个列表都用一个控制器去加载,并将其缓存
4.列表使用tableView进行展示


控件布局分析图

代码分析

先看看聚合返回的数据结构


聚合的数据结构

主控制器中,将分类条(topicView)及列表区(collectionView)作为属性

 /// 标题滚动条
 ///swift的懒加载有写法1(直接初始化):
    lazy var topicView: OYNewsTopicView = OYNewsTopicView()
    let topicArr = NewsTopicKeys
    
    /// 新闻列表用collection包裹
    let flowLayout: UICollectionViewFlowLayout = UICollectionViewFlowLayout()
 ///swift的懒加载有写法2(将懒加载代码写在闭包中,闭包要加上(),表示执行闭包):
    lazy var collectionView: UICollectionView = {
        return UICollectionView(frame: CGRect.zero, collectionViewLayout: self.flowLayout)
    }()

/// 控制器缓存,用于将每个列表的控制器进行缓存 
    var channelVcCache: [String : OYNewsChannelVC] = [String : OYNewsChannelVC]()

1.分类条,聚合提供的类型是固定不变的,将类型做为常量作为分类条的数据源

let NewsTopicKeys: [String] = ["top", "yule", "shehui", "keji", "shishang", "tiyu",  "guonei", "guoji", "junshi", "caijing"]
let NewsTopics: [String] = ["头条", "娱乐", "社会", "科技", "时尚", "体育", "国内", "国际", "军事", "财经"]

2.列表区,是一个collectionView, 其每一个cell对应一个列表, 做法如下:

返回cell的数据源方法
 func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "OYChannelCell", for: indexPath)
        for view in cell.subviews {
            guard view != cell.contentView else {
                continue
            }
            view.removeFromSuperview()
        }
        // 根据topic进行对每个列表的控制器进行缓存 
        let vc = channelVc(withTopic: topicArr[indexPath.item])
        cell.contentView.addSubview(vc.view)
        vc.view.frame = cell.bounds

        return cell
    }

// 根据topic从缓存中返回对应的控制器,如果没有,则创建新的控制器并缓存 
// 方法前加上private表示为私有函数
 private func channelVc(withTopic topic: String) -> OYNewsChannelVC {
        guard let vc = channelVcCache[topic] else {
            let newVc = OYNewsChannelVC()
            // 强引用控制器并缓存,必须强引用控制器,因为只是将控制器view添加到cell上, 当cell移出屏幕,view就会被释放掉
            addChildViewController(newVc)
            channelVcCache[topic] = newVc
            newVc.type = topic
            return newVc
        }
        return vc
    }

3.列表cell,使用自动布局,自动计算行高,聚合上提供三个字段为图片,所以设计cell时,支持1-3张图片的自适应. 后来发现,聚合返回的三张图片都是一样的,只是尺寸不同, 并且第二张和第三张是一样的url,即同一张图片,所以现在app上显示出来的都是两张图片的样式

/// 自动布局要点: 将控件从上往下进行约束,tableView要设置两个属性:
        tableView.estimatedRowHeight = 100 // 预估行高,作用是让数据源方法先返回cell,再去计算行高
        tableView.rowHeight = UITableViewAutomaticDimension // 自动计算行高
这样,cell就会根据从上到下的约束自动计算出高度,所以约束一定要写正确

/// 自动布局代码
func setupUI() -> Void {
        contentView.addSubview(titleLabel)
        contentView.addSubview(picView1)
        contentView.addSubview(picView2)
        contentView.addSubview(picView3)
        contentView.addSubview(authorLabel)
        contentView.addSubview(dateLabel)
        let width = (mainWidth-2*pictureInterMargin-2*leftRightMargin)/3
        titleLabel.numberOfLines = 0
        titleLabel.snp.makeConstraints { (make) in
            make.top.equalTo(contentView).offset(topBottomMargin)
            make.left.equalTo(contentView).offset(leftRightMargin)
            make.right.equalTo(contentView).offset(-leftRightMargin)
        }
        picView1.snp.makeConstraints { (make) in
            make.top.equalTo(titleLabel.snp.bottom).offset(interMargin)
            make.left.equalTo(contentView).offset(leftRightMargin)
            make.width.equalTo(width)
            // 因为不知道图片的尺寸,所以我都按宽高比4/3进行设置
            make.height.equalTo(width).multipliedBy(0.75)
        }
        picView2.snp.makeConstraints { (make) in
            make.top.equalTo(picView1)
            make.left.equalTo(picView1.snp.right).offset(pictureInterMargin)
            make.width.height.equalTo(picView1)
        }
        picView3.snp.makeConstraints { (make) in
            make.top.equalTo(picView2)
            make.left.equalTo(picView2.snp.right).offset(pictureInterMargin)
            make.width.height.equalTo(picView2)
        }
        authorLabel.textColor = #colorLiteral(red: 0.7233663201, green: 0.7233663201, blue: 0.7233663201, alpha: 1)
        authorLabel.font = UIFont.systemFont(ofSize: 13)
        authorLabel.snp.makeConstraints { (make) in
            make.left.equalTo(contentView).offset(leftRightMargin)
            make.top.equalTo(picView1.snp.bottom).offset(interMargin)
        }
        dateLabel.textColor = #colorLiteral(red: 0.7233663201, green: 0.7233663201, blue: 0.7233663201, alpha: 1)
        dateLabel.font = UIFont.systemFont(ofSize: 13)
        dateLabel.snp.makeConstraints { (make) in
            make.left.equalTo(authorLabel.snp.right).offset(leftRightMargin)
            make.centerY.equalTo(authorLabel)
            make.bottom.equalTo(contentView).offset(-topBottomMargin)
        }
        contentView.snp.makeConstraints { (make) in
            make.edges.equalTo(self)
        }
    }
模型的didSet方法, 相当于OC中重写set方法
var model: OYNewsModel? {
        didSet {
            let newModel = model!
            titleLabel.text = newModel.title
            authorLabel.text = newModel.author_name
            /// 对时间戳显示的处理, 我写了个分类, 有兴趣可以代码中查看
            dateLabel.text = NSDate.dateString(dateString: newModel.date, dateFormat: "yyyy-MM-dd HH:mm")
            /// 针对iOS10做的处理:sizeToFit()
            titleLabel.sizeToFit()
            authorLabel.sizeToFit()
            dateLabel.sizeToFit()
            let width = (mainWidth-CGFloat(newModel.picArr.count-1)*pictureInterMargin-2*leftRightMargin)/CGFloat(newModel.picArr.count)
            // 更新约束
            picView1.snp.updateConstraints { (make) in
                make.width.equalTo(width)
                make.height.equalTo(width*0.75)
            }
            let placeImage = UIImage(named: "colorBg")!
            /// 根据图片数量显示imageView
            switch newModel.picArr.count {
            case 1:
                picView2.isHidden = true
                picView3.isHidden = true
                picView1.sd_setImage(with: URL(string: newModel.picArr[0]), placeholderImage: placeImage)
                break
            case 2:
                picView2.isHidden = false
                picView3.isHidden = true
                picView1.sd_setImage(with: URL(string: newModel.picArr[0]), placeholderImage: placeImage)
                picView2.sd_setImage(with: URL(string: newModel.picArr[1]), placeholderImage: placeImage)
                break
            case 3:
                picView2.isHidden = false
                picView3.isHidden = false
                picView1.sd_setImage(with: URL(string: newModel.picArr[0]), placeholderImage: placeImage)
                picView2.sd_setImage(with: URL(string: newModel.picArr[1]), placeholderImage: placeImage)
                picView3.sd_setImage(with: URL(string: newModel.picArr[2]), placeholderImage: placeImage)
                break
            default:
                break
            }
            /// iOS10,layoutSubviews的方法调用次数减少,手动调用一次
            layoutIfNeeded()
        }
    }

对于iOS10,使用masonry或snapKit进行自动布局有不同的地方,原因在于ios10对于layoutSubviews的方法调用次数减少了,具体可看我之前写的文章:iOS10后使用Masonry进行自动布局出现的问题及处理

4.分类条与列表区的联动效果
这部分会比较繁琐,我们应该先把问题列出来:

  • 点击分类条上某个分类, 列表区滚动到相应的列表
  • 列表区左右滚动,分类条实时跟着滚动的偏移量对相应的两个分类颜色进行调节
  • 列表区滚动停止时,以及点击某个分类时, 分类条自动将当前选中的分类滚动到最中间, 如果当前的分类处于屏幕宽度的前半段或后半段时,则分类条只需滚动到前半段或后半段
在分类条中,我写了两个属性
/// 当前滚动的进度,用于改变topic的颜色
var process: CGFloat = 0 {
        didSet {  // didSet相当于OC中重写set方法
            let intProcess = Int(process)
            let firstProcess = process - CGFloat(intProcess)
            let secondProcess = CGFloat(intProcess+1) - process
            
            let firstModel = self.dataSource[intProcess]
            firstModel.process = 1-firstProcess
            // 实时刷新分类条的collectionView,改变颜色
            if intProcess == self.dataSource.count-1 {// 防止数组越界
                collectionView.reloadItems(at: [IndexPath(item: intProcess, section: 0)])
                return
            }
            let secondModel = self.dataSource[intProcess+1]
            secondModel.process = 1-secondProcess
            collectionView.reloadItems(at: [IndexPath(item: intProcess, section: 0), IndexPath(item: intProcess+1, section: 0)])
        }
    }
/// 当前滚动的进度,用于改变topic的偏移量
var processInt: Int = 0 {
        didSet {
            /// 记录indexPath
            lastIndexPath = IndexPath(item: processInt, section: 0)
            let offsetX = (CGFloat(processInt)+0.5)*flowLayout.itemSize.width
            if offsetX < mainWidth/2 { // 滚动到前半段
                collectionView.setContentOffset(CGPoint.zero, animated: true)
                return
            }
            if (collectionView.contentSize.width-offsetX) < mainWidth/2 { // 滚动到后半段
                collectionView.setContentOffset(CGPoint(x: collectionView.contentSize.width-mainWidth, y: 0), animated: true)
                return
            }
           // 将选中的分类滚动到最中间
            collectionView.setContentOffset(CGPoint(x: offsetX-mainWidth/2, y: 0), animated: true)
        }
    }

解决刚才提出的三个问题:
在点击某个分类时,将上一个分类的process置为0,并回调给主控制器,主控制器会将列表区滚动到相应的列表

分类条的collectionView代理方法
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        // 刷新上一个cell
        let model = self.dataSource[lastIndexPath.item]
        model.process = 0
        collectionView.reloadItems(at: [lastIndexPath])
        // 回调给主控制器
        didSelectTopic?(indexPath.item)
        lastIndexPath = indexPath
        // 分类条也滚动到相应的位置上
        processInt = indexPath.item
    }

在列表区滚动时,实时将当前滚动的process传递给分类条,实时更新颜色
在列表区滚动停止时,将当前滚动哪一个分类传递给分类条,更新分类条的偏移量

列表区的collecttionView的代理方法
func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let offsetX = scrollView.contentOffset.x
        /// 当列表区X方向的偏移量<0或超过其长度时不回调
        guard offsetX >= 0 && offsetX <= scrollView.contentSize.width-mainWidth else {
            return
        }
        let process = offsetX / mainWidth
        topicView.process = process
    }
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        let processInt = Int(scrollView.contentOffset.x / mainWidth)
        topicView.processInt = processInt
    }

好了,新闻列表的知识点已经都写出来了,具体代码请到gitHub上下载:TopOmnibus
下一篇介绍: 微信精选列表的做法, 及点击后的跳转处理从头开始写一款开源app上线,相互学习(三)
如有什么不清楚,请留言,或者直接联系我; 有错误的,请指出,谢谢

项目已上线,AppStore下载:新闻巴士

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

推荐阅读更多精彩内容