preface

Recently a company to do a news item, need to support channel editor, cache, and other functions, interface effect logic, according to the latest version of netease news online didn’t find similar wheels, rather than directly open lu, in order to do and netease effect is the same as or encounter a lot of pit and detail, this share out here, oneself do a record, You can also refer to useful words. Support manual integration or Cocoapods integration.

The project address

Github.com/yd2008/YDCh…

The final result

In fact, the basic and netease hair the same, just to be more intuitive or posted two pictures


Adjust the way

To pop up a control that takes up the full screen, it was possible to add a control to Windows prior to 7.0, but apple did not recommend this later, so it is best to present a controller directly.

public class YDChannelSelector: UIViewController
Copy the code

create

Very simple, follow the data source protocol and the proxy protocol


class ViewController: UIViewController.YDChannelSelectorDataSource.YDChannelSelectorDelegate// Channel select controllerprivate lazy var channelSelector: YDChannelSelector = {
     let sv = YDChannelSelector()
     sv.dataSource = self
     sv.delegate = self
     // Whether the local cache function is enabled by default
     // sv.isCacheLastest = false
     return sv
 }()
Copy the code

Based on the principle of interface fool, the simplest method of the outgoing call window is the present method of the system.

present(channelSelector, animated: true, completion: nil)
Copy the code

To transfer data

As a channel selector, what are the key things it needs to know?

  • Channel name
  • Whether the channel is a regular column
  • Channel’s own raw data

Based on the above requirements, I designed the channel structure

public struct SelectorItem {
    /// Channel name
    public var channelTitle: String!
    /// is it a fixed column
    public var isFixation: Bool!
    /// channel corresponds to the initial dictionary or model
    public var rawData: Any?
    public init(channelTitle: String, isFixation: Bool = false, rawData: Any?). {self.channelTitle = channelTitle
        self.isFixation = isFixation
        self.rawData = rawData
    }
}
Copy the code

Data source proxy method and tableView consistent, easy to use

public protocol YDChannelSelectorDataSource: class {
    func numberOfSections(in selector: YDChannelSelector) -> Int
    func selector(_ selector: YDChannelSelector, numberOfItemsInSection section: Int) -> Int
    func selector(_ selector: YDChannelSelector, itemAt indexPath: IndexPath) -> SelectorItem
}
Copy the code

The agent

How to notify the controller of the current status after the user performs various operations

public protocol YDChannelSelectorDelegate: class {
    /// The data source has changed
    func selector(_ selector: YDChannelSelector, didChangeDS newDataSource: [[SelectorItem]])
    /// Click the close button
    func selector(_ selector: YDChannelSelector, dismiss newDataSource: [[SelectorItem]])
    /// Click on a channel
    func selector(_ selector: YDChannelSelector, didSelectChannel channelItem: SelectorItem)
}
Copy the code

The core idea

If you are just going to use it directly, you don’t need to read it, because the following is a record of the first version of the function of the core ideas and difficulties introduced, if you are interested in extending the function or customize it.

Write above: since ios9, apple has added many powerful apis, so this plugin is based on a few new API implementation, the whole logic is still very clear. Mainly a lot of details are disgusting, the late debugging for a long time.

Control selection is immediately recognizable to the 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
}()
Copy the code

Recently deleted & user action cache

Based on the logic of netease, a new section called Recently deleted will appear in operation. In dismiss, the recently deleted channel will be moved down to my column. The idea is to manipulate the data source and add the recently deleted section in viewWillApperar. When viewDidDisappear, delete the recently-deleted section, and cache and read the user operation at the same time.

    public override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        // Process data sources according to requirements
        if isCacheLastest && UserDefaults.standard.value(forKey: operatedDS) ! =nil { // Previous data needs to be cached and user operations have storage
            // Cache the raw data source
            ifisCacheLastest { cacheDataSource(dataSource: dataSource! , isOrigin:true)}var bool = false
            letnewTitlesArrs = dataSource! .map{$0.map{$0.channelTitle! }}let orginTitlesArrs = UserDefaults.standard.value(forKey: originDS) as? [[String]]
            // The original data source was saved before
            iforginTitlesArrs ! =nil { bool = newTitlesArrs == orginTitlesArrs! }
            if bool { // Equal to the previous data -> return the cache data source
                let cacheTitleArrs = UserDefaults.standard.value(forKey: operatedDS) as? [[String]]
                letflatArr = dataSource! .flatMap { $0 }
                varcachedDataSource = 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 {  -> return new data source (not processed)}}// Preprocess the data source
        vardataSource_t = dataSource dataSource_t? .insert(latelyDeleteChannels, at:1)
        dataSource = dataSource_t
        collectionView.reloadData()
    }
    
    public override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        
        // Some operations after removing the interfacedataSource! [2] = dataSource! [1] + dataSource! [2] dataSource? .remove(at:1)
        latelyDeleteChannels.removeAll()
    }
Copy the code

User operation related

Movement mainly relies on the interface of InteractiveMovement series newly added in 9.0. The dragging effect of item is realized by adding long-press gesture to collectionView and monitoring the dragged location:

@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()
    }
}
Copy the code

The cell’s own pushdown gesture conflicts with the collectionView pushdown gesture, and the conflict needs to be resolved when the cell is created:

public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell{...// Gesture conflict resolved
    longPressGes.require(toFail: cell.longPressGes)
    ......
}
Copy the code

After careful observation, I found a detail in netease, that is, when clicking on item, it should first blink before entering the editing state. However, the touch event will be intercepted by the collectionView, so I need to define the collectionView first and rewrite the func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? Do downconversion and advance processing:

fileprivate class HitTestView: UIView {
    
    open var collectionView: UICollectionView!
    
    /// Intercept system touch event
    public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        if let indexPath = collectionView.indexPathForItem(at: convert(point, to: collectionView)) { // On a cell
            let cell = collectionView.cellForItem(at: indexPath) as! YDChannelSelectorCell
            cell.touchAnimate()
        }
        return super.hitTest(point, with: event)
    }
}

Copy the code

In edit mode, the channel cannot be dragged into more columns, and the editing action needs to be restored. Apple provides a ready-made interface, so we just need to implement the corresponding logic:

// This method controls the move and the last move to the IndexPath(when the move starts)
/// - Returns: the current expected position to move to
public func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
    letitem = dataSource! [proposedIndexPath.section][proposedIndexPath.row]if proposedIndexPath.section > 0 || item.isFixation { // It is not my column or a regular column
        return originalIndexPath
    } else {
        return proposedIndexPath
    }
}
Copy the code

Data source processing after user operations

Func handleDataSource(sourceIndexPath: IndexPath, destinationIndexPath: IndexPath), there are two call times, one is called after dragging and editing, and the other is called by clicking event. In order to cross the boundary of data source, it is processed here:

 private func handleDataSource(sourceIndexPath: IndexPath, destinationIndexPath: IndexPath) {
    letsourceStr = dataSource! [sourceIndexPath.section][sourceIndexPath.row]if sourceIndexPath.section == 0 && destinationIndexPath.section == 1 { // My column -> recently deleted
        latelyDeleteChannels.append(sourceStr)
    }
    
    if sourceIndexPath.section == 1 && destinationIndexPath.section == 0 && !latelyDeleteChannels.isEmpty { // Recently deleted -> my columnlatelyDeleteChannels.remove(at: sourceIndexPath.row) } dataSource! [sourceIndexPath.section].remove(at: sourceIndexPath.row) dataSource! [destinationIndexPath.section].insert(sourceStr, at: destinationIndexPath.row)// Notify the agentdelegate? .selector(self, didChangeDS: dataSource!)
    // Storage user operations
    cacheDataSource(dataSource: dataSource!)
}
Copy the code

The above is the core idea of the project and the specific implementation process, welcome to use, Star~ later will add OC version and external TAB slider, please look forward to! If you have a good suggestion or question, please feel free to pull request or issue me.