1. 程式人生 > >高仿網易新聞頻道選擇器

高仿網易新聞頻道選擇器

前言

前段時間公司做一個新聞類的專案,需要支援頻道編輯,快取等功能,介面效果邏輯就按照最新版的網易新聞來,網上沒找到類似的輪子,二話不說直接開擼,為了做到和網易效果一模一樣還是遇到不少坑和細節,這在此分享出來,自己做個記錄,大家覺得有用的話也可以參考。支援手動整合或者cocoapods整合。

專案地址

github.com/yd2008/YDCh…

最終效果

其實基本就和網易一毛一樣了啦,只是為了更加直觀還是貼出兩張圖片

調起方式

因為要彈出一個佔據全屏的控制元件,7.0之前可能是加在window上,但是後面蘋果不建議這麼做,所以還是直接present一個控制器出來是最優的選擇。

public class YDChannelSelector: UIViewController
複製程式碼

建立

非常簡單,遵守資料來源協議和代理協議


class ViewController: UIViewController, YDChannelSelectorDataSource, YDChannelSelectorDelegate

 // 資料來源 因為至少有當前欄目和可新增欄目,所以是二維陣列
 var selectorDataSource: [[SelectorItem]]? {
    didSet {
        // 網路非同步獲取成功時賦值即可
         channelSelector.dataSource = selectorDataSource
    }
 }
 
 // 頻道選擇控制器
private lazy var channelSelector: YDChannelSelector = { let sv = YDChannelSelector() sv.delegate = self // 是否支援本地快取使用者功能 // sv.isCacheLastest = false return sv }() 複製程式碼

基於介面傻瓜的原則,撥出視窗最簡單的方法就是系統自帶的present方法就ok。

present(channelSelector, animated: true, completion: nil)
複製程式碼

傳遞資料

作為一個頻道選擇器,它需要知道哪些關鍵資訊呢?

  • 頻道名字
  • 頻道是否是固定欄目
  • 頻道自己的原始資料

基於以上需求,我設計了頻道結構體

public struct SelectorItem {
    /// 頻道名稱
    public var channelTitle: String!
    /// 是否是固定欄目
    public var isFixation: Bool!
    /// 頻道對應初始字典或模型
    public var rawData: Any?
    public init(channelTitle: String, isFixation: Bool = false, rawData: Any?) {
        self.channelTitle = channelTitle
        self.isFixation = isFixation
        self.rawData = rawData
    }
}
複製程式碼

實現資料來源代理裡的資料介面

public protocol YDChannelSelectorDataSource: class {
    /// selector 資料來源
    var selectorDataSource: [[SelectorItem]]? { get }
}
複製程式碼

代理

使用者做了各種操作後如何通知控制器當前狀態

public protocol YDChannelSelectorDelegate: class {
    /// 資料來源發生變化
    func selector(_ selector: YDChannelSelector, didChangeDS newDataSource: [[SelectorItem]])
    /// 點選了關閉按鈕
    func selector(_ selector: YDChannelSelector, dismiss newDataSource: [[SelectorItem]])
    /// 點選了某個頻道
    func selector(_ selector: YDChannelSelector, didSelectChannel channelItem: SelectorItem)
}
複製程式碼

核心思路

如果你只是打算直接用的話那下面已經不用看了,因為以下是記錄初版功能實現的核心思路以及難點介紹,如果感興趣想自己擴充套件功能或者自定義的話可以看看。

寫在前面: ios9以後蘋果又添加了很多強大的api,所以本外掛主要基於幾個新api實現,整個邏輯還是很清晰明瞭。主要是很多細節比較噁心,後期除錯了很久。

控制元件選擇一眼就能看出 UICollectionView

private lazy var collectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        layout.minimumLineSpacing = itemMargin
        layout.minimumInteritemSpacing = itemMargin
        layout.itemSize = CGSize(width: itemW, height: itemH)
        let cv = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
        cv.contentInset = UIEdgeInsets.init(top: 0, left: itemMargin, bottom: 0, right: itemMargin)
        cv.backgroundColor = UIColor.white
        cv.showsVerticalScrollIndicator = false
        cv.delegate = self
        cv.dataSource = self
        cv.register(YDChannelSelectorCell.self, forCellWithReuseIdentifier: YDChannelSelectorCellID)
        cv.register(YDChannelSelectorHeader.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: YDChannelSelectorHeaderID)
        cv.addGestureRecognizer(longPressGes)
        return cv
}()
複製程式碼

最近刪除 & 使用者操作快取

基於網易的邏輯,在操作時會出現一個新的section叫最近刪除,dismiss時把最近刪除的頻道下移到我的欄目,思路就是在viewWillApperar時操縱資料來源,新增最近刪除section,在viewDidDisappear時整理使用者操作,移除最近刪除section,與此同時進行使用者操作的快取和讀取,具體實現程式碼如下:

    public override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        // 根據需求處理資料來源
        if isCacheLastest && UserDefaults.standard.value(forKey: operatedDS) != nil { // 需要快取之前資料 且使用者操作有儲存
            // 快取原始資料來源
            if isCacheLastest { cacheDataSource(dataSource: dataSource!, isOrigin: true) }
            var bool = false
            let newTitlesArrs = dataSource!.map { $0.map { $0.channelTitle! } }
            let orginTitlesArrs = UserDefaults.standard.value(forKey: originDS) as? [[String]]
            // 之前有存過原始資料來源
            if orginTitlesArrs != nil { bool = newTitlesArrs == orginTitlesArrs! }
            if bool { // 和之前資料相等 -> 返回快取資料來源
                let cacheTitleArrs = UserDefaults.standard.value(forKey: operatedDS) as? [[String]]
                let flatArr = dataSource!.flatMap { $0 }
                var cachedDataSource = cacheTitleArrs!.map { $0.map { SelectorItem(channelTitle: $0, rawData: nil) }}
                for (i,items) in cachedDataSource.enumerated() {
                    for (j,item) in items.enumerated() {
                        for originItem in flatArr {
                            if originItem.channelTitle == item.channelTitle {
                                cachedDataSource[i][j] = originItem
                            }
                        }
                    }
                }
                dataSource = cachedDataSource
            } else {  // 和之前資料不等 -> 返回新資料來源(不處理)
                
            }
        }
        
        // 預處理資料來源
        var dataSource_t = dataSource
        dataSource_t?.insert(latelyDeleteChannels, at: 1)
        dataSource = dataSource_t
        collectionView.reloadData()
    }
    
    public override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        
        // 移除介面後的一些操作
        dataSource![2] = dataSource![1] + dataSource![2]
        dataSource?.remove(at: 1)
        latelyDeleteChannels.removeAll()
    }
複製程式碼

使用者操作相關

移動主要依賴9.0新增的InteractiveMovement系列介面,通過給collectionView新增長按手勢並監聽拖動的location實現item拖動效果:

@objc private func handleLongGesture(ges: UILongPressGestureRecognizer) {
    guard isEdit == true else { return }
    switch(ges.state) {
    case .began:
        guard let selectedIndexPath = collectionView.indexPathForItem(at: ges.location(in: collectionView)) else { break }
        collectionView.beginInteractiveMovementForItem(at: selectedIndexPath)
    case .changed:
        collectionView.updateInteractiveMovementTargetPosition(ges.location(in: ges.view!))
    case .ended:
        collectionView.endInteractiveMovement()
    default:
        collectionView.cancelInteractiveMovement()
    }
}
複製程式碼

這裡有個小坑就是cell自己的長按手勢會和collectionView的長按手勢衝突,需要在建立cell的時候做衝突解決:

public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    ......
    // 手勢衝突解決
    longPressGes.require(toFail: cell.longPressGes)
    ......
}
複製程式碼

仔細觀察發現網易的有個細節,就是點選item的時候要先閃爍一下在進入編輯狀態,但是觸碰事件會被collectionView攔截,所以要先自定義collectionView,重寫func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?做下轉換和提前處理:

fileprivate class HitTestView: UIView {
    
    open var collectionView: UICollectionView!
    
    /// 攔截系統觸碰事件
    public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        if let indexPath = collectionView.indexPathForItem(at: convert(point, to: collectionView)) { // 在某個cell上
            let cell = collectionView.cellForItem(at: indexPath) as! YDChannelSelectorCell
            cell.touchAnimate()
        }
        return super.hitTest(point, with: event)
    }
}

複製程式碼

在編輯模式頻道不能拖到更多欄目裡面,需要還原編輯動作,蘋果提供了現成介面,我們只需要實現相應邏輯即可:

/// 這個方法裡面控制需要移動和最後移動到的IndexPath(開始移動時)
/// - Returns: 當前期望移動到的位置
public func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
    let item = dataSource![proposedIndexPath.section][proposedIndexPath.row]
    if proposedIndexPath.section > 0 || item.isFixation { // 不是我的欄目 或者是固定欄目
        return originalIndexPath
    } else {
        return proposedIndexPath
    }
}
複製程式碼

使用者操作後的資料來源處理

使用者操作完後對資料來源要操作方法是func handleDataSource(sourceIndexPath: IndexPath, destinationIndexPath: IndexPath), 呼叫時間有兩個,一是拖動編輯後呼叫,二就是點選事件呼叫,為了資料來源越界統一在此處理:

 private func handleDataSource(sourceIndexPath: IndexPath, destinationIndexPath: IndexPath) {
    let sourceStr = dataSource![sourceIndexPath.section][sourceIndexPath.row]
    if sourceIndexPath.section == 0 && destinationIndexPath.section == 1 { // 我的欄目 -> 最近刪除
        latelyDeleteChannels.append(sourceStr)
    }
    
    if sourceIndexPath.section == 1 && destinationIndexPath.section == 0 && !latelyDeleteChannels.isEmpty { // 最近刪除 -> 我的欄目
        latelyDeleteChannels.remove(at: sourceIndexPath.row)
    }
    
    dataSource![sourceIndexPath.section].remove(at: sourceIndexPath.row)
    dataSource![destinationIndexPath.section].insert(sourceStr, at: destinationIndexPath.row)
    
    // 通知代理
    delegate?.selector(self, didChangeDS: dataSource!)
    // 儲存使用者操作
    cacheDataSource(dataSource: dataSource!)
}
複製程式碼

以上就是專案核心思路和具體實現過程,歡迎使用,求Star~ 後續還會新增外部的tab選擇欄,敬請期待!你有好的建議或者問題也可隨時pull request或者issue我。