Possible to customize position and layout of .outlineDisclosure Option for UICollectionView (list style)?

1k Views Asked by At

Currently I have the following layout:
enter image description here

But I want to get this layout for the headers (expanded state):
enter image description here

So essentialy I need a custom view for the disclosure indicator because for the expanded state it should point to the bottom and for the collapsed state it should point at the top (see above picture). In addition the disclosure indicator should be on the left.

What I have done so far
With this code I get the first example:

class ViewController: UIViewController {
    
    enum ListItem: Hashable {
        case header(HeaderItem)
        case symbol(SFSymbolItem)
    }
    
    let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout())
    
    var dataSource: UICollectionViewDiffableDataSource<HeaderItem, ListItem>!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        configureCollectionView()
        setupDataSource()
        updateDate(items: HeaderItem.modelObjects)
    }

    private func configureCollectionView() {
        let layoutConfig = UICollectionLayoutListConfiguration(appearance: .plain)
        let listLayout = UICollectionViewCompositionalLayout.list(using: layoutConfig)
        
        collectionView.register(CustomListCell.self, forCellWithReuseIdentifier: CustomListCell.reuseID)
        
        collectionView.collectionViewLayout = listLayout
        
        view.addSubview(collectionView)

        collectionView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(collectionView)
        
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.topAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }
    
    private func setupDataSource() {
        let headerCellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, HeaderItem> {
            (cell, indexPath, headerItem) in

            var config = cell.defaultContentConfiguration()
            config.text = headerItem.title
            cell.contentConfiguration = config
            
            let headerDisclosureOption = UICellAccessory.OutlineDisclosureOptions(style: .header)
            cell.accessories = [.outlineDisclosure(options: headerDisclosureOption)]
            
            //cell.accessories = [.customView(configuration: .init(customView: UIView(), placement: .trailing(displayed: .always, at: .center), isHidden: <#T##Bool?#>, reservedLayoutWidth: <#T##UICellAccessory.LayoutDimension?#>, tintColor: <#T##UIColor?#>, maintainsFixedSize: <#T##Bool?#>))]
        }

        let symbolCellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, SFSymbolItem> {
            (cell, indexPath, symbolItem) in
            
            var config = cell.defaultContentConfiguration()
            config.text = symbolItem.name
            cell.contentConfiguration = config
        }
        
        dataSource = UICollectionViewDiffableDataSource<HeaderItem, ListItem>(collectionView: collectionView) {
            (collectionView, indexPath, listItem) -> UICollectionViewCell? in
            
            switch listItem {
            case .header(let headerItem):
                
                // Dequeue header cell
                let cell = collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration,
                                                                        for: indexPath,
                                                                        item: headerItem)
                return cell
                
            case .symbol(let symbolItem):
                
                // Dequeue symbol cell
                let cell = collectionView.dequeueConfiguredReusableCell(using: symbolCellRegistration,
                                                                        for: indexPath,
                                                                        item: symbolItem)
                return cell
            }
        }
    }
    
    private func updateDate(items: [HeaderItem]) {
        var snapshot = NSDiffableDataSourceSnapshot<HeaderItem, ListItem>()
        snapshot.appendSections(items)
        
        for headerItem in items {
            
            //section snapshot
            var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<ListItem>()
            let headerListItem = ListItem.header(headerItem)
            sectionSnapshot.append([headerListItem])
            
            
            let symbolListItemArray = headerItem.symbols.map { ListItem.symbol($0) }
            sectionSnapshot.append(symbolListItemArray, to: headerListItem)
            
            dataSource.apply(sectionSnapshot, to: headerItem, animatingDifferences: false)
            
        }
    }
}

Model:

struct HeaderItem: Hashable {
    let title: String
    let symbols: [SFSymbolItem]
}

struct SFSymbolItem: Hashable {
    let name: String
    let image: UIImage
    
    init(name: String) {
        self.name = name
        self.image = UIImage(systemName: name)!
    }
}

extension HeaderItem {
    static let modelObjects = [
        
        HeaderItem(title: "Communication", symbols: [
            SFSymbolItem(name: "mic"),
            SFSymbolItem(name: "mic.fill"),
            SFSymbolItem(name: "message"),
            SFSymbolItem(name: "message.fill"),
        ]),
        
        HeaderItem(title: "Weather", symbols: [
            SFSymbolItem(name: "sun.min"),
            SFSymbolItem(name: "sun.min.fill"),
            SFSymbolItem(name: "sunset"),
            SFSymbolItem(name: "sunset.fill"),
        ]),
        
        HeaderItem(title: "Objects & Tools", symbols: [
            SFSymbolItem(name: "pencil"),
            SFSymbolItem(name: "pencil.circle"),
            SFSymbolItem(name: "highlighter"),
            SFSymbolItem(name: "pencil.and.outline"),
        ]),
        
    ]
}

Edit:
I was able to get closer to the desired result by using .customView(...) as accesory type.

let testAction = UIAction(image: UIImage(systemName: "chevron.up"), handler: { [weak self] _ in
    //expand / collapse programmatically
})

let testBtn = UIButton(primaryAction: testAction)

let customAccessory = UICellAccessory.CustomViewConfiguration(
    customView: testBtn,
    placement: .leading(displayed: .always))

cell.accessories = [.customView(configuration: customAccessory)]

enter image description here
Is it possible to do the collapsing / expanding programmatically by clicking on the customView?

1

There are 1 best solutions below

0
Ganaraj Savant On

Here's a simple workaround, but it will fulfill your requirement. Instead of creating a custom accessory view simply use the .image property of the UIListContentConfiguration and change it based on the state. With that, your headerCellRegistration will look something like the below:

let headerCellRegistration = UICollectionView.CellRegistration<HeaderListCell, HeaderItem> {
    (cell, indexPath, headerItem) in
    
    var config = cell.defaultContentConfiguration()
    config.text = headerItem.title
    cell.contentConfiguration = config
    
    //    Simply make the outlineDisclosure accessory invisible but functional.
    let headerDisclosureOption = UICellAccessory.OutlineDisclosureOptions(style: .header, tintColor: .clear)
    cell.accessories = [.outlineDisclosure(options: headerDisclosureOption)]
}

class HeaderListCell: UICollectionViewListCell {
    override func updateConfiguration(using state: UICellConfigurationState) {
        super.updateConfiguration(using: state)
        
        guard var cConfig = self.contentConfiguration?.updated(for: state) as? UIListContentConfiguration else { return }
        cConfig.image = state.isExpanded ? UIImage(systemName: "chevron.down") : UIImage(systemName: "chevron.right")
        cConfig.imageProperties.reservedLayoutSize = CGSize(width: 12.0, height: 12.0)
        cConfig.imageToTextPadding = 16.0
        
        self.contentConfiguration = cConfig
    }
}

Hope this solves your problem, cheers!