高仿網易新聞頻道選擇器
前言
前段時間公司做一個新聞類的專案,需要支援頻道編輯,快取等功能,介面效果邏輯就按照最新版的網易新聞來,網上沒找到類似的輪子,二話不說直接開擼,為了做到和網易效果一模一樣還是遇到不少坑和細節,這在此分享出來,自己做個記錄,大家覺得有用的話也可以參考。支援手動整合或者cocoapods整合。
專案地址
最終效果
其實基本就和網易一毛一樣了啦,只是為了更加直觀還是貼出兩張圖片
調起方式
因為要彈出一個佔據全屏的控制元件,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!)
}
複製程式碼