Skip to content

Repository files navigation

SSCollectionViewPresenter

🎞️ Super Simple abstraction layer for building UICollectionView-based UIs with minimal boilerplate.

SwiftPM compatibleSwiftPlatformLicense: MIT

Motivation

Implementing UICollectionView across various screens often involves repetitive, error-prone tasks — registering cells, configuring data sources and delegates, or adapting raw server responses to data models. As these tasks pile up screen after screen, the codebase becomes tedious to maintain, especially when each screen handles things a little differently.

The core issue is a lack of separation between rendering logic and interaction logic. Each screen ends up owning too much — it knows how to display data, how to respond to events, and how to talk to the rest of the app.

The design of this library was heavily inspired by pkh0225/CollectionViewAdapter, which solved exactly these problems in a way that felt immediately practical. Introducing a ViewModel as the single source of truth eliminated the data synchronization issues that tend to creep in when view controllers manage collection state directly. Having the adapter take full ownership of UICollectionViewDataSource and UICollectionViewDelegate meant that individual screens no longer needed to reimplement the same boilerplate — they simply bind to a ViewModel and react. And seeing real-world e-commerce features baked into the library made it clear just how much repetitive work a well-designed abstraction can eliminate in production codebases.

SSCollectionViewPresenter follows that same philosophy, while integrating SendingState as its backbone. The presenter drives the UI through type-safe ViewModel binding, and events emitted by cells flow upward through a shared event channel — keeping UI code focused on rendering and interaction logic easy to trace.

Philosophy

SSCollectionViewPresenter is built on a pragmatic take on Apple's MVC architecture:

  • Lightweight business logic can remain in the UIViewController.
  • For more complex interactions, an Interactor can be introduced to separate concerns.
  • UI components like UICollectionViewCell can forward user interactions (buttons, gestures, toggles) to an Interactor or UIViewController.

How It Works

You provide a ViewModel containing:

  • A list of SectionInfo
  • Each section has a list of CellInfo (and optional header/footer via ReusableViewInfo)

Then, simply bind the ViewModel to the presenter. The presenter handles:

  • Drawing the correct section/cell
  • Registering cells and reusable views
  • Managing layout & display logic

There's no need to implement UICollectionViewDataSource manually.


Key Features

Boilerplate-free UICollectionView setup

No need to write custom data sources and delegates repeatedly. The presenter takes full ownership of UICollectionViewDataSource and UICollectionViewDelegate — screens simply bind to a ViewModel and react.

Automatic cell/header/footer registration

Cells and reusable views are registered automatically using type-safe identifiers. NIB files are detected and loaded without any extra configuration.

Section layout control

Each section can carry its own insets, line spacing, interitem spacing, and column count — configurable either through the builder API or via direct assignment on SectionInfo.

Built-in RESTful API pagination

Tracks page and hasNext out of the box. Supports both append-only pagination via extendViewModel and structured page management via loadPage — including per-page replacement and removal.

Server-driven UI composition

Conforms to ServerStateSectionRepresentable and ServerStateUnitRepresentable to compose the UI based on a shared server-client contract. Section and item ordering is determined by the server response — no hardcoded layout decisions on the client side.

Infinite scrolling & auto-rolling

Smooth circular scroll behavior for banner carousels, with optional center-snapping and configurable auto-roll intervals.

Page lifecycle callbacks

Observe and respond to page-level events: onPageWillAppear, onPageDidAppear, onPageWillDisappear, onPageDidDisappear.

Drag & Drop reordering

Supports long-press drag reordering within the collection view. On iPad, external drag & drop is also supported — items can be dragged into or out of other apps using NSItemProvider and UTType-based filtering.

Diffable & traditional data source support

Switch between UICollectionViewDiffableDataSource and the traditional data source with a single parameter at setup time.

Flow layout & compositional layout

Full support for both UICollectionViewFlowLayout and UICollectionViewCompositionalLayout.

Re-exported dependency

SendingState is re-exported, so you can use Configurable, EventForwardingProvider, and other types without an extra import.


Quick Start

1. Define Your Model

structBannerData:Decodable{letid:Stringlettitle:StringletimgUrl:String}

2. Create a Custom Cell

Conform to SSCollectionViewCellProtocol, which inherits from Configurable (provided by SendingState).

finalclassBannerCell:UICollectionViewCell,SSCollectionViewCellProtocol{@IBOutlet weak vartitleLabel:UILabel!@IBOutlet weak varimgView:UIImageView!staticfunc size(with input:BannerData?, constrainedTo parentSize:CGSize?)->CGSize?{CGSize(width: parentSize?.width ??100, height:200)}varconfigurer:(BannerCell,BannerData)->Void{{ view, model in
view.titleLabel.text = model.title
view.imgView.loadWebImage(model.imgUrl)}}}

3. Set Up in Your ViewController

finalclassHomeViewController:UIViewController{@IBOutlet weak varcollectionView:UICollectionView!overridefunc viewDidLoad(){
super.viewDidLoad()
collectionView.ss.setupPresenter()letbanners=[BannerData(id:"1", title:"Summer Sale", imgUrl:"https://your.image.url"),BannerData(id:"2", title:"Winter Deals", imgUrl:"https://your.image.url")]
// Option A: Manual construction
letsectionInfo=SSCollectionViewModel.SectionInfo()forbannerin banners {
sectionInfo.appendCellInfo(banner, cellType:BannerCell.self)}letviewModel=SSCollectionViewModel(sections:[sectionInfo])
collectionView.ss.setViewModel(with: viewModel)
// Option B: Builder pattern
collectionView.ss.buildViewModel{ builder in
builder.section{
builder.cells(banners, cellType:BannerCell.self)}}
collectionView.reloadData()}}

Guides

Section Layout Options

Each section can carry its own layout properties — insets, spacing, and column count — either via direct assignment on SectionInfo or through the builder API.

Using the builder (recommended):

collectionView.ss.buildViewModel{ builder in
builder.section("productList"){
builder.sectionInset(UIEdgeInsets(top:20, left:16, bottom:20, right:16))
builder.minimumLineSpacing(10)
builder.minimumInteritemSpacing(16)
builder.gridColumnCount(2)
builder.cells(products, cellType:ProductCell.self)}}

Using direct assignment:

letsection=SSCollectionViewModel.SectionInfo()
section.sectionInset =UIEdgeInsets(top:20, left:16, bottom:20, right:16)
section.minimumLineSpacing =10
section.minimumInteritemSpacing =16
section.gridColumnCount =2

Available layout properties:

PropertyTypeDescription
sectionInsetUIEdgeInsets?Padding around the section's items
minimumLineSpacingCGFloat?Minimum spacing between successive rows or columns
minimumInteritemSpacingCGFloat?Minimum spacing between items in the same row or column
gridColumnCountInt?Number of columns. 0 ignores section insets and stretches each item to full width. A positive value distributes items evenly across the row.

Cell Interaction & Event Handling

Simple actions with actionClosure

For straightforward interactions — a tap, a toggle — attach an actionClosure directly in the builder. The closure receives an action name and an optional input payload.

// Cell
builder.cells(products, cellType:ProductCell.self){ indexPath, cell, action, input inswitch action {case"addToCart":addToCart(at: indexPath)default:break}}
// Header / Footer
builder.header(headerData, viewType:SectionHeaderView.self){ section, view, action, input inswitch action {case"more":showMore(for: section)default:break}}

Complex actions with EventForwarder (SendingState)

When a cell or view emits multiple event types, carries typed payloads, or needs to share a single event channel across sections, conform to EventForwardingProvider from SendingState instead.

finalclassProductCell:UICollectionViewCell,SSCollectionViewCellProtocol,EventForwardingProvider{
// UI
@IBOutlet weak varcartButton:UIButton!@IBOutlet weak varclipButton:UIButton!@IBOutlet weak varlensButton:UIButton!@IBOutlet weak varproductDetailButton:UIButton!@IBOutlet weak varrefreshButton:UIButton!
// ...
varconfigurer:(ProductCell,ProductModel)->Void{{ view, model in
// configuration code
}}vareventForwarder:EventForwardable{SenderGroup{EventForwarder(cartButton){ sender, ctx in
ctx.control(.touchUpInside){(state:ProductModel)in[TestAction.cart(state.productId)]}}EventForwarder(clipButton){ sender, ctx in
ctx.control(.touchUpInside)(state: ProductModel){[TestAction.clip(state.productId)]}}EventForwarder(lensButton){ sender, ctx in
ctx.control(.touchUpInside)(state: ProductModel){[TestAction.aiSearch(state.productId)]}}EventForwarder(productDetailButton){ sender, ctx in
ctx.control(.touchUpInside)(state: ProductModel){[TestAction.goProductDetail(state.productId)]}}EventForwarder(refreshButton){ sender, ctx in
ctx.control(.touchUpInside){[TestAction.refresh(sender.tag)]}}}}}

Observe events at the view-controller level through the presenter's shared event channel.

For full EventForwardingProvider usage, refer to the SendingState documentation.


Handling delegate events inside cells

Cells can respond to delegate-level events by implementing optional methods from SSCollectionViewCellProtocol:

finalclassMyCell:UICollectionViewCell,SSCollectionViewCellProtocol{
// ...
func didSelect(with input:MyModel?){
// Handle selection
}func willDisplay(with input:MyModel?){
// Called just before the cell appears
}func didEndDisplaying(with input:MyModel?){
// Called after the cell disappears
}}

Available lifecycle methods:

MethodDescription
willDisplay(with:)Called before the view appears
didEndDisplaying(with:)Called after the view disappears
didHighlight(with:)Called on touch-down
didUnhighlight(with:)Called on touch-up
didSelect(with:)Called on selection
didDeselect(with:)Called on deselection

willDisplay and didEndDisplaying are available on both cells and reusable views (headers/footers).


Reconfiguring Items, Headers, and Footers

reconfigureItem(_:at:), reconfigureHeader(_:at:), and reconfigureFooter(_:at:) replace the underlying state (model) of a visible view and re-invoke its configurer in place — no full reload needed. Use this whenever only the data of an existing view has changed.

// Update a cell's state
letupdated=ProductModel(id:"00000011", title:"Test Product", price:30)
collectionView.ss.reconfigureItem(updated, at: indexPath)
// Update a header / footer
collectionView.ss.reconfigureHeader(SectionHeaderData(title:"New Event"), at:0)
collectionView.ss.reconfigureFooter(FooterData(text:"More Events"), at:0)

Collapse & Expand Sections

toggleSection(_:completion:) flips the isCollapsed flag of the given section and triggers a data source update. The completion closure delivers the new collapsed state — use it to push updated state into any header or footer whose appearance depends on it (a chevron, a label, etc.).

collectionView.ss.toggleSection(sectionIndex){[weak self] collapsed inguardlet self =selfelse{return}letupdated=SectionHeaderData(title:"Products", isExpanded: !collapsed)self.collectionView.ss.reconfigureHeader(updated, at: sectionIndex)}

Triggering from an actionClosure

The section parameter passed into a header or footer actionClosure is the section index itself, so it can be forwarded directly to toggleSection:

builder.header(headerData, viewType:SectionHeaderView.self){[weak self] section, view, action, input inguardlet self =selfelse{return}switch action {case"toggle":guardlet state = input as?SectionHeaderDataelse{return}self.collectionView.ss.toggleSection(section){ collapsed inletupdated=SectionHeaderData(title: state.title, isExpanded: !collapsed)self.collectionView.ss.reconfigureHeader(updated, at: section)}default:break}}

Triggering from an EventForwarder

When using EventForwardingProvider, the handler receives no index by default. The view must embed its own position in the forwarded payload so the handler can pass it to toggleSection.

  • Headers / footers — include sectionIndex
  • Cells — include indexPath
// Header view — embed sectionIndex in the payload
finalclassSectionHeaderView:UICollectionReusableView,EventForwardingProvider{@IBOutlet weak varfilterButton:UIButton!@IBOutlet weak varsortButton:UIButton!@IBOutlet weak varsearchButton:UIButton!@IBOutlet weak varcloseButton:UIButton!@IBOutlet weak varcollapseButton:UIButton!varconfigurer:(SectionHeaderView,SectionHeaderModel)->Void{{ view, model in
view.titleLabel.text = model.title
view.collapseButton.setImage(UIImage(named:"chevron_down"), for:.normal)
view.collapseButton.setImage(UIImage(named:"chevron_right"), for:.selected)
view.collapseButton.isSelected = model.isExpanded ?false:true}}vareventForwarder:EventForwardable{SenderGroup{EventForwarder(filterButton){ sender, ctx in
ctx.control(.touchUpInside){[TestAction.filter]}}EventForwarder(sortButton){ sender, ctx in
ctx.control(.touchUpInside){[TestAction.sort]}}EventForwarder(searchButton){ sender, ctx in
ctx.control(.touchUpInside){[TestAction.search]}}EventForwarder(closeButton){ sender, ctx in
ctx.control(.touchUpInside){[TestAction.close]}}EventForwarder(collapseButton){ sender, ctx in
ctx.control(.touchUpInside){[weak self]inguardlet self,let state =self.ss.state()else{return[TestAction]()}return[TestAction.toggle(self.sectionIndex, state)]}}}}}
// Action handler (view controller or interactor)
finalclassTestStoreViewController:UIViewController,ActionHandlingProvider{@IBOutlet weak varcollectionView:UICollectionView!
// ...
func handle(action:TestAction){switch action {case.toggle(let sectionIndex):
collectionView.ss.toggleSection(sectionIndex, state){[weak self] collapsed inguardlet self =selfelse{return}letnewState=SectionHeaderModel(title: state.title, isExpanded: !collapsed)self.collectionView.ss.reconfigureHeader(newState, at: sectionIndex)}default:break}}}

Loading Next Page (Pagination)

extendViewModel is useful for simple append-only pagination. For more structured control — such as replacing or removing individual pages — use loadPage instead.

If your collection view should load more data when the user scrolls near the end, use onNextRequest:

collectionView.ss.onNextRequest{[weak self] viewModel inguardlet self =selfelse{return}NetworkingManager.fetchNextPage(current: viewModel.page){ result inguard case .success(let response)= result else{return}self.collectionView.ss.extendViewModel(
page: response.page,
hasNext: response.hasNext
){ builder in
builder.section("productList"){
builder.cells(response.products, cellType:ProductCell.self)}}self.collectionView.reloadData()}}

extendViewModel merges by section identifier — if a section with the same ID exists, new items are appended to it. Otherwise, a new section is added.

Page-Based Data Management with loadPage

For typical RESTful APIs that return paginated responses, loadPage lets you store each page's sections independently. The presenter merges all stored pages into a single flat list internally — sections with the same identifier across pages are concatenated, while unnamed sections are simply appended.

loadPage accepts either an array of SectionInfo or a builder closure:

// Initial load
collectionView.ss.loadPage(0, hasNext:true){ builder in
builder.section("banner"){
builder.cells(banners, cellType:BannerCell.self)}
builder.section("productList"){
builder.cells(products, cellType:ProductCell.self)}}
collectionView.reloadData()

Combine it with onNextRequest to handle pagination seamlessly:

collectionView.ss.onNextRequest{[weak self] viewModel inguardlet self =selfelse{return}NetworkingManager.fetchNextPage(current: viewModel.page +1){ result inguard case .success(let response)= result else{return}self.collectionView.ss.loadPage(response.page, hasNext: response.hasNext){ builder in
builder.section("productList"){
builder.cells(response.productList, cellType:ProductCell.self)}}self.collectionView.reloadData()}}

Because each page is stored separately, you can replace or remove any individual page without affecting the rest:

// Replace page 2 with fresh data (e.g. after an item edit)
collectionView.ss.loadPage(2, hasNext:true){ builder in
builder.section("productList"){
builder.cells(updatedProducts, cellType:ProductCell.self)}}
// Remove a specific page
collectionView.ss.removePage(2)
// Pull-to-refresh: clear everything and start over
varviewModel= collectionView.ss.getViewModel()
viewModel?.removeAllPages()
collectionView.ss.setViewModel(with: viewModel ??SSCollectionViewModel())

You can also query page state directly on the view model:

Property / MethodDescription
pageThe most recently loaded page number
hasNextWhether more pages are available
pageCountNumber of stored pages
hasPageDatatrue if at least one page is stored
sections(forPage:)Returns the sections for a specific page
findPage(forSectionIdentifier:)Finds the latest page containing a given section ID

Merge rules: When multiple pages contain sections with the same identifier, their items are merged into one section in page order. Headers and footers from later pages take precedence. Sections without an identifier are never merged — they're always appended as separate sections.


Server-Driven UI Composition

This pattern was born out of hands-on experience developing module units for Template stores at SSG.COM. In that system, each page is composed of a server-defined list of sections — called templates — and each template contains an ordered set of UI modules called units. The server owns both the structure and the ordering of the page; the client simply renders whatever it receives, without hardcoding any layout decisions into the view controller. Working within that contract at production scale made the value of a clean, protocol-driven abstraction immediately obvious — and that experience shaped the design of this feature directly.

SendingState provides the server-driven UI contracts through two protocols:

  • ServerStateSectionRepresentable — represents a single section returned by the server, carrying an optional sectionId and an ordered list of units.
  • ServerStateUnitRepresentable — represents a single UI module within a section, identified by a unitType string and an associated unitData payload.

SSCollectionViewPresenter can use these contracts to render server-provided sections and units.

collectionView.ss.buildViewModel{ builder in
builder.sections(
result.sectionList,
configureSection:{ section, builder inguardlet sectionId = section.sectionId else{return}switch sectionId {case"ProductList":
builder.sectionInset(.init(top:20, left:15, bottom:20, right:15))
builder.minimumLineSpacing(15)case"TripleItems":
builder.sectionInset(.init(top:20, left:10, bottom:20, right:10))
builder.minimumLineSpacing(10)
builder.minimumInteritemSpacing(1)default:
builder.sectionInset(.zero)
builder.minimumLineSpacing(0)
builder.minimumInteritemSpacing(0)}},
configureUnit:{ unit, builder inswitch unit.unitType {case"SS_TOP_BANNER":guardlet bannerList = unit.unitData as?[BannerModel]else{return}
builder.cell(bannerList, cellType:TopBannerCell.self)case"SS_PRODUCT_LIST":guardlet productList = unit.unitData as?[ProductModel]else{return}
builder.cells(productList, cellType:ProductCell.self)case"SS_MY_FAVORITES":guardlet myFavorites = unit.unitData as?MyFavoritesModelelse{return}iflet titleInfo = myFavorites.titleInfo {
builder.header(titleInfo, viewType:MyFavoriteHeaderView.self)}
builder.cells(myFavorites.productList, cellType:ProductCell.self)default:break}})}
collectionView.reloadData()

configureSection runs first for each section — allowing layout properties to be applied before configureUnit adds the cells. Both closures receive the builder, so the full layout API remains available at every stage.

Tip — conforming to the protocols: Define one conforming type per screen or API context, since each endpoint typically follows its own data contract. If two screens share the same structure but differ in layout rules, prefer subclassing over duplication. If configureUnit closures start to look repetitive across screens, extract the shared logic into a factory.

If the server doesn't provide section identifiers and instead returns a nested array of units, decode the response as [[any ServerStateUnitRepresentable]] and initialize a conforming type per inner array — setting sectionId to nil or a derived index value.


Infinite Scroll & Auto-Rolling

Enable infinite scrolling or auto-rolling banners with a single call:

// Center-aligned paging with infinite scroll and auto-rolling
collectionView.ss.setPagingEnabled(.init(
isAlignCenter:true,
isInfinitePage:true,
isAutoRolling:true,
autoRollingTimeInterval:4.0))

PagingConfiguration parameters:

ParameterDefaultDescription
isEnabledtrueEnables custom paging (replaces UIScrollView.isPagingEnabled)
isAlignCenterfalseSnaps the current page to the center of the viewport
isLoopingfalseWraps around when reaching either end
isInfinitePagefalseEnables infinite scrolling by duplicating content
isAutoRollingfalseAutomatically scrolls at a fixed interval
autoRollingTimeInterval3.0Seconds between auto-scroll transitions

isInfinitePage vs isLooping: Both create a wrap-around effect, but they differ in feel. isInfinitePage duplicates content to produce seamless, continuous scrolling — ideal for banners where the transition should feel uninterrupted. isLooping snaps back to the first page explicitly, which can feel cleaner when the jump is intentional. Choose based on the UX you're after.

Requirements (flow layout): This feature requires a single section with uniformly-sized items. For best results, avoid headers/footers and disable isPagingEnabled on the scroll view.

You can also control paging programmatically:

collectionView.ss.moveToNextPage()
collectionView.ss.moveToPreviousPage()

Page Lifecycle Callbacks

Track which page a user is viewing — useful for analytics, journey maps, or triggering animations:

collectionView.ss.onPageWillAppear{ collectionView, pageIndex inprint("Page \(pageIndex) is about to appear")}
collectionView.ss.onPageDidAppear{ collectionView, pageIndex inprint("Page \(pageIndex) appeared")}
collectionView.ss.onPageWillDisappear{ collectionView, pageIndex inprint("Page \(pageIndex) is about to disappear")}
collectionView.ss.onPageDidDisappear{ collectionView, pageIndex inprint("Page \(pageIndex) disappeared")}

ScrollView Delegate Forwarding

If you need to observe scroll events from outside the presenter:

collectionView.ss.setScrollViewDelegateProxy(self)

The presenter will forward UIScrollViewDelegate calls to the proxy.


Drag & Drop Reordering

Enable reordering with a single call:

collectionView.ss.setReorderEnabled(true)

To restrict which items can be dragged:

collectionView.ss.onCanDragItem{ cellInfo in
// Return false to prevent dragging that item
return cellInfo.identifier !="pinned"}

Observe reorder events before and after they apply:

collectionView.ss.onWillReorder{ items inprint("About to move: \(items.map{ $0.indexPath })")}
collectionView.ss.onDidReorder{ items, destination inprint("Moved to: \(destination)")}

To customize the drag preview:

// Custom view
collectionView.ss.setDragPreviewProvider{ cellInfo inletview=MyPreviewView()
view.configure(with: cellInfo)return view
}
// Custom parameters (e.g. corner radius, shadow)
collectionView.ss.onDragPreviewParameters{ indexPath inletparams=UIDragPreviewParameters()
params.visiblePath =UIBezierPath(roundedRect:.init(x:0, y:0, width:120, height:120), cornerRadius:8)return params
}

External Drag & Drop (iPad)

On iPad, items can be dragged into or out of other apps. Supply an NSItemProvider for outgoing drags and register a handler for incoming drops:

// Outgoing — provide a payload for external drops
collectionView.ss.setDragItemProvider{ cell, cellInfo inguardlet text = cellInfo.data as?Stringelse{returnnil}returnNSItemProvider(object: text asNSString)}
// Incoming — specify accepted types and handle the drop
collectionView.ss.setAcceptedExternalDropTypeIdentifiers([UTType.plainText.identifier])
collectionView.ss.onExternalDrop{ value, indexPath inguardlet text = value as?Stringelse{returnnil}returnSSCollectionViewModel.CellInfo(data: text, cellType:MyCell.self)}

Advanced Setup

Diffable Data Source

To use the modern diffable data source (iOS 13+), pass .diffable when setting up:

collectionView.ss.setupPresenter(dataSourceMode:.diffable)
collectionView.ss.buildViewModel{ builder in
builder.section("main"){
builder.cells(items, cellType:ItemCell.self)}}
// Use applySnapshot instead of reloadData
collectionView.ss.applySnapshot(animated:true)

When using diffable mode, call applySnapshot(animated:) instead of reloadData() to apply changes with optional animations.


Compositional Layout

For more advanced layouts, use .compositional with SSCompositionalLayoutSection (iOS 13+):

letsections=[SSCompositionalLayoutSection(
direction:.horizontal,
columns:1,
height:200,
scrolling:.paging
),SSCompositionalLayoutSection(
direction:.vertical,
columns:2,
height:150)]letconfig=SSCollectionViewPresenter.CompositionalLayoutConfig(sections: sections)
collectionView.ss.setupPresenter(layoutKind:.compositional(config))

SSCompositionalLayoutSection parameters:

ParameterTypeDescription
directionUICollectionView.ScrollDirection.horizontal or .vertical
columnsIntNumber of columns (default: 1)
itemWidthCGFloat?Fixed item width; if nil, auto-calculated from columns
heightCGFloatItem height
scrollingScrollingBehavior?Orthogonal scrolling behavior (none, continuous, paging, etc.)

Installation

SSCollectionViewPresenter is available via Swift Package Manager.

Using Xcode:

  1. Open your project in Xcode
  2. Go to File > Add Packages...
  3. Enter the URL:
https://github.com/dSunny90/SSCollectionViewPresenter
  1. Select the version and finish

Using Package.swift:

dependencies:[.package(url:"https://github.com/dSunny90/SSCollectionViewPresenter", from:"1.0.0")]

About

Super Simple abstraction layer for building `UICollectionView`-based UIs with minimal boilerplate.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages