高仿網易新聞頻道選擇器
前段時間公司做一個新聞類的專案,需要支援頻道編輯,快取等功能,介面效果邏輯就按照最新版的網易新聞來,網上沒找到類似的輪子,二話不說直接開擼,為了做到和網易效果一模一樣還是遇到不少坑和細節,這在此分享出來,自己做個記錄,大家覺得有用的話也可以參考。支援手動整合或者cocoapods整合。
專案地址
ofollow,noindex">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!) } 複製程式碼