手把手带你撸一个网易云音乐首页(三)

15,620 阅读14分钟

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!」

前言

Hello, 大家好,今天准备和大家继续分享如何利用 Swift 来实现一个网易云音乐的首页;上俩篇文章文章发布以后,我收获了不少小伙伴的关注与点赞,同时也得到了一些非常有用的建议,在这里再次感谢大家的认可, 你们的鼓励与建议是我技术输出路上最大的动力。

MVVM

好了,回到正题,在项目中我们使用了 MVVM 模式,在上一篇文章中,我们讲完了 Model 和 ViewModel, 那接下来就开始讲第三部分 View 吧!如果有小伙伴是从这篇文章进入的,不妨先从我的上一篇文章看起,这样看下来才能保证你思路的连贯性。

View

回到我们的项目工程中来,准备构建我们的表视图。

首先,在我们的首页视图控制器 DiscoveryViewController 中创建存储属性 HomeViewModel 并初始化它。在我们实际开发过程中,数据请求的操作必不可少,必须要先将数据提供给 ViewModel,然后在数据更新时重新 Reload TableView。

    // 首页发现 viewModel
    fileprivate var homeViewModel = HomeViewModel()

接下来,我们来配置 tableViewDataSource:

    // Mark UITableViewDataSource
    override func numberOfSections(in tableView: UITableView) -> Int {
        if homeViewModel.sections.isEmpty {
            return 0
        }
        return homeViewModel.sections.count
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int{
        return homeViewModel.sections[section].rowCount
    }

现在我们就可以开始构建 UI 了。根据网易云音乐的样式,我们需要创建 12 种不同类型的 Cell, 每种 Cell 对应一种 ViewModelItems。

为了进一步的提高代码的质量,我们可以为这些 Cell 定义一个基类 BaseViewCell,这样通过该基类,我们就可以设置一些默认的属性,减少一些不必要的编码工作;另外,通过观察你会发现,大部分的 Section 都会包含一个 headView。关于 headView 的实现方式,想必使用过 UITableView 的同学都不会陌生,可以通过下面的方法来实现:

- (nullable UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section;   // custom view for header. will be adjusted to default or specified header height

但是,在这个项目中,我并不打算使用上面的方法来实现 headView,主要原因是因为网易云音乐的每个 Section 都是有圆角效果的,如果我们定义了 viewForHeaderInSection,那么我们在实现圆角的时候就需要做如下的逻辑:

  • 给 headView 的左上角和右上角添加圆角效果
  • 给 Section 里的 Cell 的左下角和右下脚添加圆角效果

如图所示:

image

我们知道,要为一个视图添加圆角是非常有讲究的,如果直接调用 cornerRadius 和 masksToBounds 这俩个方法设置圆角就会出现离屏渲染,况且我们的首页有很多圆角视图,到时候首页加载显示就会感受到明显的卡顿,这样的体验可不好!而且使用这俩个方法也无法为视图指定设置圆角的方位,是要左上角呢还是右下角?

上面讲到为视图设置圆角一不小心就会造成离屏渲染,那么这个问题该如何解决呢!在这里,我们可以通过利用 UIBezierPath 来为视图绘制圆角,以及还可以指定画圆角的方位:

func roundCorners(_ rect: CGRect, corners: UIRectCorner, radius: CGFloat) {
        let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
        let mask = CAShapeLayer()
        mask.path = path.cgPath
        self.layer.mask = mask
    }

考虑到如果通过 viewForHeaderInSection 方法来创建 HeadView,那么我们就要为俩个视图来绘制圆角,分别是 TableViewCell 和 viewForHeaderInSection 创建的 headView。这里我想了一个比较好的办法,只需要调用一次绘制方法即可,那就是将我们的 headView 实现在我们的 tableViewCell 中,如下所示:

image

另外,因为每个 Section 都有 headView ,所以我们可以在 BaseViewCell 这个基类中去实现这个头视图:

/// UITableViewCell 的基类
class BaseViewCell: UITableViewCell {
    
    var headerView: JJTableViewHeader?
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        self.backgroundColor = UIColor.homeCellColor
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

接下来,我们来构建具体的 Cell ,由于代码过多,这里仅展示部分代码:

/// 首页 Bannerl
class ScrollBannerCell: BaseViewCell {
    class var identifier: String {
          return String(describing: self)
    }
    
    var scrollBanner: JJNewsBanner!
    
    var item: HomeViewModelSection? {
        didSet {
            guard let item = item as? BannerModel else {
                return
            }
            self.setupUI(model: item)
        }
    }
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        /// 初始化
        scrollBanner = JJNewsBanner(frame: CGRect.zero)
        self.contentView.addSubview(scrollBanner!)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
    }
    
    func setupUI(model: BannerModel) {
        self.scrollBanner.frame = model.frame
        self.scrollBanner.updateUI(model: model, placeholderImage: UIImage(named: "ad_placeholder"))
    }
}


/// 首页-发现 圆形按钮
class CircleMenusCell: BaseViewCell {
    class var identifier: String {
          return String(describing: self)
    }
    
    var homeMenu: HomeMenu!
    
    var item: HomeViewModelSection? {
        didSet {
            guard let item = item as? MenusModel else {
                return
            }
            self.setupUI(model: item)
        }
    }
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        /// 初始化
        homeMenu = HomeMenu(frame: CGRect.zero)
        self.contentView.addSubview(homeMenu!)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
    }
    
    func setupUI(model: MenusModel) {
        self.homeMenu.frame = model.frame
        self.homeMenu.updateUI(data: model.data)
    }
}

....

在现实中,每个 Cell 所展示的视图样式都是非常丰富的,于是我们必须为 Cell 创建不同的 UI 样式,每种样式对应自己的数据 Model。

构建 TableViewCell 样式

图片轮播效果

首先,网易云音乐最上层是一个图片轮播的效果,如何构建这个 Banner 呢!这里就不绕弯子了,当然是用最常用的内容展示神器 UICollectionView 这个控件了,读完本篇文章你会发现真是万物皆可使用 UICollectionView。

具体实现该效果的代码在这里我就不做多阐述了,因为在我之前的文章中,我已经将实现这个效果的教程写出来了,查看此文即可:使用 UICollectionView 实现首页卡片轮播效果

圆形菜单入口

该效果实现起来很简单,唯一有意思之处在于“每日歌曲推荐”这个按钮上中间的文字是会随着日期改变的,如图:

image

不过实现起来也简单,中间放一个 Label 即可。如该侧面图所示(图借用自作者 Leo):

image

整体实现用的控件还是 UICollectionView。部分代码如下:

import UIKit
import Foundation
import SnapKit
import Kingfisher

class HomeMenuCell: UICollectionViewCell {
    
    lazy var menuLayer: UIView = {
        let view = UIView()
        view.backgroundColor = UIColor.darkModeMenuColor
        return view
    }()
    
    lazy var menuIcon: UIImageView = {
        let mIcon = UIImageView()
        mIcon.tintColor = UIColor.dragonBallColor
        return mIcon
    }()
    
    lazy var menuText: UILabel = {
        let mText = UILabel()
        mText.textColor = UIColor.darkModeTextColor
        mText.textAlignment = .center
        mText.font = UIFont.systemFont(ofSize: 12)
        return mText
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        self.contentView.addSubview(self.menuLayer)
        self.menuLayer.addSubview(self.menuIcon)
        self.contentView.addSubview(self.menuText)
        
        self.menuLayer.snp.makeConstraints { (make) in
            make.centerX.equalToSuperview()
            make.width.equalTo(self.frame.size.width * 0.6)
            make.height.equalTo(self.frame.size.width * 0.6)
        }
        
        self.menuIcon.snp.makeConstraints { (make) in
            make.centerX.equalToSuperview()
            make.centerY.equalToSuperview()
            make.width.equalTo(self.frame.size.width * 0.6)
            make.height.equalTo(self.frame.size.width * 0.6)
        }
        
        self.menuText.snp.makeConstraints { (make) in
            make.centerX.equalToSuperview()
            make.bottom.equalToSuperview()
            make.height.equalTo(self.frame.size.width * 0.4)
            make.width.equalTo(self.frame.size.width)
        }
        
        // 设置菜单圆角
        self.menuLayer.layer.cornerRadius = self.frame.size.width * 0.6 * 0.5
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func setupUI(imageUrl: String, title: String) -> Void {
        let cache = KingfisherManager.shared.cache
        let imgModify = RenderingModeImageModifier(renderingMode: .alwaysTemplate)
        let optionsInfo = [KingfisherOptionsInfoItem.imageModifier(imgModify),
                        KingfisherOptionsInfoItem.targetCache(cache)]

        self.menuIcon.kf.setImage(with: URL(string: imageUrl), placeholder: nil, options: optionsInfo, completionHandler:  { ( result ) in
            
        })
        self.menuText.text = title
    }
}

推荐歌单/音乐视频/雷达歌单/视频合集等

先看下 UI 效果:

image image image

因为这些 UI 的效果是差不多的,第一个冒出来想法就是在 Cell 中放置 UICollectionView,它的布局也很简单,直接用系统提供的即可,不需要我们去自定义布局。

像这种上图下文的 CollectionViewCell 也很好定义,这里就不多做阐述,部分代码如下:

import UIKit
import SnapKit
import Kingfisher

class CardViewCell: UICollectionViewCell {
    /// 封面
    lazy var albumCover: UIImageView! = {
        let cover = UIImageView()
        cover.backgroundColor = UIColor.clear
        cover.contentMode = .scaleAspectFill
        return cover
    }()
    
    /// 描述
    lazy var albumDesc: UILabel! = {
        let descLabel = UILabel()
        descLabel.backgroundColor = UIColor.clear
        descLabel.font = UIFont.systemFont(ofSize: 12)
        descLabel.numberOfLines = 0
        return descLabel
    }()
    
    /// 阅读量
    var views: String?
    
    /// 内边距
    let padding: CGFloat = 5
    
    /// 阅读量按钮
    lazy var viewsButton: UIButton! = {
        let button = UIButton(type: .custom)
        button.titleLabel?.font = UIFont.systemFont(ofSize: 10)
        button.backgroundColor = UIColor(red: 182/255, green: 182/255, blue: 182/255, alpha: 0.6)
        button.setImage(UIImage(named: "Views"), for: .normal)
        button.setTitleColor(.white, for: .normal)
        return button
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        self.backgroundColor = .clear
        self.addSubview(self.albumCover)
        self.albumCover.addSubview(self.viewsButton)
        self.addSubview(self.albumDesc)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        let height: CGFloat = self.bounds.height
        let width: CGFloat = self.bounds.width
        
        let descHeight: CGFloat = height * (1/4)
        
        // 封面样式设置
        self.albumCover.snp.makeConstraints { (make) in
            make.width.equalTo(width)
            make.height.equalTo(width)
            make.centerX.equalToSuperview()
            make.top.equalToSuperview()
        }
        self.albumCover.roundCorners(self.albumCover.bounds, corners: [.allCorners], radius: 10)
       
        // 设置按钮样式
        let viewsRect = self.getStrBoundRect(str: self.views!, font: self.viewsButton.titleLabel!.font, constrainedSize: CGSize.zero)
        let viewsW = viewsRect.width
        let viewsH = viewsRect.height * 1.2
        self.viewsButton.frame = CGRect(x: self.albumCover.frame.width - viewsW - padding, y: padding, width: viewsW, height: viewsH)
        self.viewsButton.moveImageLeftTextCenterWithTinySpace(imagePadding: 5)
        self.viewsButton.roundCorners(self.viewsButton.bounds, corners: [.allCorners], radius: viewsW * 0.2)
        
        self.albumDesc.snp.makeConstraints { (make) in
            make.width.equalTo(width - 10)
            make.height.equalTo(descHeight)
            make.centerX.equalToSuperview()
            make.top.equalTo(self.albumCover.snp.bottom).offset(5)
        }
    }
    
    ....
}
/// 通用的卡片滚动视图,该控件适用于横向滚动并且上图下文形式
class CardCollectionView: UIView {
    
.....
        
    /// 布局
    lazy var cardFlowLayout: UICollectionViewFlowLayout = {
        let layout = UICollectionViewFlowLayout()
        layout.minimumLineSpacing = margin
        layout.minimumInteritemSpacing = 0
        layout.sectionInset = UIEdgeInsets.init(top: -20, left: margin, bottom: 0, right: 0)
        layout.scrollDirection = .horizontal
        return layout
    }()
    
    
    /// 歌单的视图
    lazy var hotAlbumContainer: UICollectionView = {
        let collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: self.cardFlowLayout)
        collectionView.register(CardViewCell.self, forCellWithReuseIdentifier: RecomendAlbumId)
        collectionView.isPagingEnabled = true
        collectionView.showsVerticalScrollIndicator = false
        collectionView.showsHorizontalScrollIndicator = false
        collectionView.delegate = self
        collectionView.dataSource = self
        collectionView.backgroundColor = UIColor.clear
        collectionView.bounces = false
        return collectionView
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.addSubview(self.hotAlbumContainer)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        self.hotAlbumContainer.frame = CGRect(x: 0, y: 0, width: self.bounds.width, height: self.bounds.height)
        // 设置 item size 大小
        self.cardFlowLayout.itemSize = CGSize(width: itemA_width * scaleW, height: self.frame.size.height - 3 * margin)
        
    }
    
    deinit {
        self.hotAlbumContainer.delegate = nil
        self.hotAlbumContainer.dataSource = nil
    }
}

// MARK: - UICollectionViewDelegate
extension CardCollectionView: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        
    }
}

// MARK: - UICollectionViewDataSource
extension CardCollectionView: UICollectionViewDataSource {
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        if self.songList == nil {
            return 0
        }
        return self.songList!.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: RecomendAlbumId, for: indexPath) as! CardViewCell
        let result:Creative = self.songList![indexPath.row]
        if result.creativeType == "voiceList" {
            cell.updateUI(coverUrl: (result.uiElement?.image!.imageURL)!, desc: (result.uiElement?.mainTitle!.title)!, views: String((result.creativeEXTInfoVO?.playCount)!))
        } else {
            let element = result.resources?[0]
            cell.updateUI(coverUrl: (element?.uiElement.image.imageURL)!, desc: (element?.uiElement.mainTitle.title)!, views: String((element?.resourceEXTInfo?.playCount)!))
        }
        
        return cell
    }
}

个性推荐/新歌新碟数字专辑/

接下来,咱们来构建另外的样式。先来看下 UI:

image

image

由于“个性推荐”,“新歌新碟数字专辑”这俩个功能的样式是差不多的,所以也将这俩并在一起说。在这我还是选择在 Cell 中放置 UICollectionView。但是,通过观察你会发现它的 UI 样式其实是有讲究的,就是在同一个页面中,它的第二个 item 也需要露出一部分,这该如何去实现呢!

image

为了能在一个页面中出现俩个 item,那我们必须要减少 itemSize 的宽度,这样设置 UICollectionViewFlowLayout 后就能在一个页面中出现俩个 item 了。

我们知道在 UICollectionView 的属性中,有一个分页的属性:isPagingEnabled,当设置成 true 时,每次滚动的位移量等于它自身 frame 的宽度;当不设置这个分页属性,它的默认值是 false, 所以它的滚动就不会有分页的效果。

OK,那这个想法是不是正确呢!其实当你动手实践后,你会发现这样实现后会有一个非常头疼的 bug,那就当 item 滚动的时候会出现遮挡,这用户体贴也太差了。

image

有人要问那是不是 UICollectionView 这个控件就只能按照屏幕的大小来分页呢!答案当然是否定的。我们还可以用自定义的方式来实现分页滚动。根据文档,Apple 在 UICollectionViewFlowLayout 的定义中提供了一个可重写的函数:

func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint // return a point at which to rest after scrolling - for layouts that want snap-to-point scrolling behavior

这个函数的返回值,决定了 UICollectionView 停止滚动时的偏移量,可以通过重写这个函数来实现自定义的分页滚动,重写这个函数的逻辑思路如下:

  1. 定义一个坐标点 CGPoint 来记录最新滚动的偏移坐标
  2. 定义俩个值分别为 UICollectionView 可滚动的最大偏移量与最小偏移量也是就 0
  3. 每次滚动停止都会调用上述的函数 func targetContentOffset(...), 在这个函数中有一个参数 proposedContentOffset 记录了滚动的目标位移坐标,通过这个坐标和记录的上次滚动的坐标可以判断出是向左滚动还是向右滚动
  4. 如果俩坐标的水平方向相减的绝对值大于某个固定值(譬如说 item 宽度的 8 分之一),则可以判断发生了分页,然后通过 proposedContentOffset 位移坐标和 item 的宽度大小来计算出当前滚动的页码;如果小于那个固定值,则不发生分页
  5. 最后记录最新的偏移坐标,然后返回 UICollectionView 停止滚动时的偏移量

代码实现如下:

class RowStyleLayout: UICollectionViewFlowLayout {
    
    private var lastOffset: CGPoint!
    
    override init() {
        super.init()
        lastOffset = CGPoint.zero
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // 初始化
    override func prepare() {
        
        super.prepare()
        self.collectionView?.decelerationRate = .fast
    }
    
    // 这个方法的返回值,决定了 CollectionView 停止滚动时的偏移量
    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
        
        // 分页的 width
        let pageSpace = self.stepSpace()
        let offsetMax: CGFloat = self.collectionView!.contentSize.width - (pageSpace + self.sectionInset.right + self.minimumLineSpacing)
        let offsetMin: CGFloat = 0
        
        // 修改之前记录的位置,如果小于最小的contentsize或者最大的contentsize则重置值
        if lastOffset.x < offsetMin {
            lastOffset.x = offsetMin
        } else if lastOffset.x > offsetMax{
            lastOffset.x = offsetMax
        }
        
        // 目标位移点距离当前点距离的绝对值
        let offsetForCurrentPointX: CGFloat = abs(proposedContentOffset.x - lastOffset.x)
        let velocityX = velocity.x
        
        // 判断当前滑动方向,向左 true, 向右 fasle
        let direction: Bool = (proposedContentOffset.x - lastOffset.x) > 0
        
        var newProposedContentOffset: CGPoint = CGPoint.zero
        
        if (offsetForCurrentPointX > pageSpace/8.0) && (lastOffset.x >= offsetMin) && (lastOffset.x <= offsetMax) {
            // 分页因子,用于计算滑过的cell数量
            var pageFactor: NSInteger = 0
            if velocityX != 0 {
                // 滑动
                // 速率越快,cell 滑过的数量越多
                pageFactor = abs(NSInteger(velocityX))
            } else {
                // 拖动
                pageFactor = abs(NSInteger(offsetForCurrentPointX / pageSpace))
            }
            
            //设置 pageFactor 的上限为2,防止滑动速率过大,导致翻页过多
            pageFactor = pageFactor < 1 ? 1: (pageFactor < 3 ? 1: 2)
            
            let pageOffsetX: CGFloat = pageSpace * CGFloat(pageFactor)
            newProposedContentOffset = CGPoint(x: lastOffset.x + (direction ? pageOffsetX : -pageOffsetX), y: proposedContentOffset.y)
        } else {
            // 滚动距离小于翻页步距,则不进行翻页
            newProposedContentOffset = CGPoint(x: lastOffset.x, y: lastOffset.y)
        }
        
        lastOffset.x = newProposedContentOffset.x
        return newProposedContentOffset
    }
    
    // 每滑动一页的间距
    public func stepSpace() -> CGFloat {
        return self.itemSize.width + self.minimumLineSpacing
    }
}

在我之前的文章中,我已经将实现这个效果的教程写出来了,查看此文即可:使用 UICollectionView 实现分页滑动效果

音乐日历

UI 如图:

image

音乐日历的效果,不需要支持横向滚动,所以这里可以选择在 Cell 中放置一个 UIView,对有一点 iOS 开发基础的同学来说,实现这样的 UI 应该不难,大家可以通过 Xib 或者代码的方式来实现,Xib 实现起来应该更快,这里我就不在多做说明了。

播客

终于讲到最后一个 UI 了,先看下效果:

image

经历过构建上面这么多 UI 后,想必看到这个效果,大家都心知肚明了,还有比用 UICollectionView 更简单的方式了吗? 同样是构建一个上图下文的 Cell, 只不过播客需要将图片加上圆角,代码实现起来也很简单,这里也不做多阐述了。

搜索

关于如何构建不同的 Cell 到这里就讲完了,如果大家有疑问的话,欢迎在评论区或者我的公号中发信息给我。

接下来,我们开始讲首页的最后一部分---搜索框。在网易云音乐首页的最顶层有一个视图,视图包含的内容有三部分:左按钮,搜索框,右按钮,这种结构很容易让我们联想到 UINavigationItem。没错,利用 UINavigationItem 来实现这样的 UI 结构是最有效的。

由于我们工程里首页控制器是继承自 UITableViewController 的,所以我们可以直接设置它 UINavigationItem 属性中的 leftBarButtonItem,titleView 和 rightBarButtonItem:

// 设置搜索视图
    func setupSearchController () {
        let leftItem = UIBarButtonItem(image: UIImage(named: "menu")?.withRenderingMode(.alwaysOriginal), style: UIBarButtonItem.Style.plain, target: self, action: #selector(menuBtnClicked))
        let rightItem = UIBarButtonItem(image: UIImage(named: "microphone")?.withRenderingMode(.alwaysOriginal), style: UIBarButtonItem.Style.plain, target: self, action: #selector(microphoneBtnClicked))
        self.navigationItem.leftBarButtonItem = leftItem
        self.navigationItem.rightBarButtonItem = rightItem
        
        self.cusSearchBar = JJCustomSearchbar(frame: CGRect(x: 0, y: 0, width: 200, height: 50))
        self.cusSearchBar.delegate = self
        self.navigationItem.titleView = self.cusSearchBar
    }

自定义 UISearchBar,代码如下:

class JJCustomSearchbar: UISearchBar {
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.searchTextField.placeholder = "has not been"
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func adjustPosition() {
        var frame :CGRect
        frame = self.searchTextField.frame
        // 获取 placeholder 大小
        let r = self.searchTextField.placeholderRect(forBounds: self.searchTextField.bounds)
        let offset = UIOffset(horizontal: (frame.size.width - r.width - 40)/2, vertical: 0)
        self.setPositionAdjustment(offset, for: .search)
    }
}

当我们点击顶部的搜索框时,页面需要跳转到真正的搜索页面,所以我们需要实现 UISearchBarDelegate 代理函数:

extension DiscoveryViewController: UISearchBarDelegate {
    // 点击跳转
    func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
        self.musicSearchController = MusicSearchViewController()
        self.navigationController?.pushViewController(self.musicSearchController, animated: false)
        return true
    }
}

构建跳转后的搜索页面

首先,需要实现搜索视图,我们的视图控制器 MusicSearchViewController 继承自 UITableViewController,所以它的 UINavigationItem 中自己带有 searchController。不过,由于搜索栏需要自定义一些样式,我们可以先定义一个 UISearchController 的成员变量,将它的属性初始化好以后,再进行赋值,代码如下:

   self.searchController = UISearchController(searchResultsController: nil)
   self.searchController.delegate = self
   self.searchController.searchResultsUpdater = self
   self.searchController.searchBar.delegate = self
   self.searchController.searchBar.placeholder = "Search"
   self.searchController.searchBar.autocapitalizationType = .none
   self.searchController.dimsBackgroundDuringPresentation = false
        
   self.navigationItem.hidesBackButton = true
   self.navigationItem.searchController = self.searchController
   self.navigationItem.searchController?.isActive = true
   self.navigationItem.hidesSearchBarWhenScrolling = false
   definesPresentationContext = true

在本工程,我们仅实现一个简单的搜索演示功能,因为要真的做好搜索这个需求,需要服务器的”大力“配合,在本工程中,我们仅用一些静态数据来做演示:

musics = [
            Results(name: "如果爱"),
            Results(name: "情书"),
            Results(name: "龙卷风"),
            Results(name: "半岛铁盒"),
            Results(name: "世界末日"),
            Results(name: "爱在西元前"),
            Results(name: "等你下课"),
            Results(name: "黑色幽默"),
            Results(name: "我不配")
        ]

数据源有了,接下来就是来实现数据查找功能了,在搜索栏中输入要搜索的歌名,并在页面上列出我们搜索到的结果。这里就需要来实现 UISearchResultsUpdating 和 UISearchBarDelegate 这俩个代理了,通过 UISearchBar 获取到输入值,然后在提供的数据源中查找,并 reload 我们的表视图:

extension MusicSearchViewController: UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        let searchBar = searchController.searchBar
        filterContentForSearchText(searchBar.text!)
    }
}

extension MusicSearchViewController: UISearchBarDelegate{
    func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
        filterContentForSearchText(searchBar.text!)
    }
    
    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        self.navigationController?.popViewController(animated: true)
    }
    
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        self.searchController.searchBar.resignFirstResponder()
    }
}

func filterContentForSearchText(_ searchText: String){
        filteredMusic = musics.filter{ music in
            return music.name.lowercased().contains(searchText.lowercased()) || searchText == ""
        }
        
        tableView.reloadData()
    }

结尾

到此,使用 MVVM 来构建网易云音乐首页就差不多讲完了,我们再总结一下,在本文中我们主要讲解了如何来构建 UI 视图, 由于在我们首页里的 Cell 的样式有不同之处但也有相似的地方,所以我们创建了一个基类 BaseViewCell, 用于展示 Cell 中相同的地方;然后我们在各个 Cell 中构建不同样式的 UI,利用 UICollectionView 这一神器实现了这些效果;最后,实现了简单的搜索功能。

好了,以上便是本次分享~ 下一篇我们来讲讲关于 App 的适配。

本篇文章的所有代码都在我的 Github 上 欢迎 star ✨。

往期文章:

请你喝杯 ☕️ 点赞 + 关注哦~

  1. 阅读完记得给我点个赞哦,有👍 有动力

  2. 关注公众号--- HelloWorld杰少,第一时间推送新姿势

原创文章,文笔有限,文中若有不正之处,万望告知。