[Разработка под iOS, Разработка мобильных приложений, Swift] Подходы к спискам на UICollectionView

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
14-Апр-2021 19:30

ВведениеУже давным давно, во всех известных нам галактиках мобильные приложения представляют информацию в виде списков - будь то доставка еды на Татуине, имперская почта или обычный ежедневник джедая. С незапамятных времен мы писали UI на UITableView и не задумывались. Копились бесчисленные баги и знания об устройстве этого инструмента и о лучших практиках. И когда мы получили очередной infinite scroll дизайн, мы поняли: пришло время задуматься и дать отпор тирании UITableViewDataSource и UITableViewDelegate.Почему коллекция?До сих пор коллекции пребывали в тени, многие побаивались их чрезмерной гибкости или считали их функционал избыточным. В самом деле, почему бы просто не использовать стек или таблицу? Если для первого мы быстро упремся в низкую производительность, то со вторым в отсутсвие гибкости при реализации лейаута элементов.Так ли страшны коллекции и какие подводные камни они в себе таят? Мы сравнили.
  • Ячейки в таблице содержат лишние элементы: content view, group editing view, slide actions view, accessory view.
  • Использование UICollectionView дает единообразность при работе с любыми списками объектов, так как ее API в целом схож с UITableView.
  • Коллекция позволяет применять нестандартные виды лейаута, а так же связанные с ним атрибуты анимированных транзишнов.
Так же у нас были некоторые опасения:
  • Возможность использовать Pull to refresh
  • Отсутсвие лагов при отрисовке
  • Возможность скролла в ячейках
Но в ходе реализации все они развеялись.Избавившись от класса таблицы, мы смогли написать расширяемый для целого семейства списков адаптер с возможностью в любой момент безболезненно вернуться к таблице под капотом.
АдаптерыКоллекции это, конечно, хорошо, но пробовали ли вы избавиться от привычного боилерплейта с датасорсами и делегатами, чтобы создание экранного списка занимало не больше 10 строк? Для сравнения, вспомним классическую реализацию экрана со списком на UITableView.
final class CurrencyViewController: UIViewController {
    var tableView = UITableView()
    var items: [ViewModel] = []
    func setup() {
        tableView.delegate = self
        tableView.dataSource = self
        tableView.backgroundColor = .white
        tableView.rowHeight = 72.0
        tableView.contentInset = .init(top: Constants.topSpacing, left: 0, bottom: Constants.bottomSpacing, right: 0)
        tableView.reloadData()
    }
}
extension CurrencyViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        output.didSelectBalance(at: indexPath.row)
    }
}
extension CurrencyViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        items.count
    }
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusable(cell: object.cellClass, at: indexPath)
        cell.setup(with: object)
        return cell
    }
}
extension UITableView {
    func dequeueReusable(cell type: UITableViewCell.Type, at indexPath: IndexPath) -> UITableViewCell {
        if let cell: UITableViewCell = self.dequeueReusableCell(withIdentifier: type.name()) {
            return cell
        }
        self.register(cell: type)
        let cell: UITableViewCell = self.dequeueReusableCell(withIdentifier: type.name(), for: indexPath)
        return cell
    }
    private func register(cell type: UITableViewCell.Type) {
        let identifier: String = type.name()
        self.register(type, forCellReuseIdentifier: identifier)
     }
}
Приходят на помощь джедаи адаптеры.Напомним, что паттерн адаптер наделяет исходный объект новым интерфейсом, с которым в данном контексте удобно работать. Наш адаптер конечно лишь этим не ограничился.Ниже приведен пример такого использования.
private let listAdapter = CurrencyVerticalListAdapter()
private let collectionView = UICollectionView(
    frame: .zero,
    collectionViewLayout: UICollectionViewFlowLayout()
)
private var viewModel: BalancePickerViewModel
func setup() {
    listAdapter.setup(collectionView: collectionView)
    collectionView.backgroundColor = .c0
    collectionView.contentInset = .init(top: Constants.topSpacing, left: 0, bottom: Constants.bottomSpacing, right: 0)
    listAdapter.onSelectItem = output.didSelectBalance
    listAdapter.heightMode = .fixed(height: 72.0)
    listAdapter.spacing = 8.0
    listAdapter.reload(items: viewModel.items)
}
Однако внутри адаптер представляет собой даже не один класс. Рассмотрим для начала базовый (и вообще говоря абстрактный) класс адаптера списков:
public class ListAdapter<Cell> : NSObject, ListAdapterInput, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDragDelegate, UICollectionViewDropDelegate, UIScrollViewDelegate where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.RegistrableView {
    public typealias Model = Cell.Model
    public typealias ResizeCallback = (_ insertions: [Int], _ removals: [Int], _ skipNext: Bool) -> Void
    public typealias SelectionCallback = ((Int) -> Void)?
    public typealias ReadyCallback = () -> Void
    public enum DragAndDropStyle {
        case reorder
        case none
    }
    public var dragAndDropStyle: DragAndDropStyle { get set }
    internal var headerModel: ListHeaderView.Model?
    public var spacing: CGFloat
    public var itemSizeCacher: UICollectionItemSizeCaching?
    public var onSelectItem: ((Int) -> Void)?
    public var onDeselectItem: ((Int) -> Void)?
    public var onWillDisplayCell: ((Cell) -> Void)?
    public var onDidEndDisplayingCell: ((Cell) -> Void)?
    public var onDidScroll: ((CGPoint) -> Void)?
    public var onDidEndDragging: ((CGPoint) -> Void)?
    public var onWillBeginDragging: (() -> Void)?
    public var onDidEndDecelerating: (() -> Void)?
    public var onDidEndScrollingAnimation: (() -> Void)?
    public var onReorderIndexes: (((Int, Int)) -> Void)?
    public var onWillBeginReorder: ((IndexPath) -> Void)?
    public var onReorderEnter: (() -> Void)?
    public var onReorderExit: (() -> Void)?
    internal func subscribe(_ subscriber: AnyObject, onResize: @escaping ResizeCallback)
    internal func unsubscribe(fromResize subscriber: AnyObject)
    internal func subscribe(_ subscriber: AnyObject, onReady: @escaping ReadyCallback)
    internal func unsubscribe(fromReady subscriber: AnyObject)
    internal weak var collectionView: UICollectionView?
    public internal(set) var items: [Model] { get set }
    public func setup(collectionView: UICollectionView)
    public func setHeader(_ model: ListHeaderView.Model)
    public subscript(index: Int) -> Model? { get }
    public func reload(items: [Model], needsRedraw: Bool = true)
    public func insertItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)
    public func appendItem(_ item: Model, allowDynamicModification: Bool = true)
    public func deleteItem(at index: Int, allowDynamicModification: Bool = true)
    public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>)
    public func deleteItems(at indexes: [Int], allowDynamicModification: Bool = true)
    public func updateItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)
    public func reloadItems(_ newItems: [Model], at range: PartialRangeFrom<Int>, allowDynamicModification: Bool = true)
    public func reloadItems(_ newItems: [Model], at indexes: [Int], allowDynamicModification: Bool = true)
    public func reloadItems(_ newItems: [(index: Int, element: Model)], allowDynamicModification: Bool = true)
    public func moveItem(at index: Int, to newIndex: Int)
    public func performBatchUpdates(updates: @escaping (ListAdapter) -> Void, completion: ((Bool) -> Void)?)
    public func performBatchUpdates(updates: () -> Void, completion: ((Bool) -> Void)?)
}
public typealias ListAdapterCellConstraints = UICollectionViewCell & RegistrableView & AnimatedConfigurableView
public typealias VerticalListAdapterCellConstraints = ListAdapterCellConstraints & HeightMeasurableView
public typealias HorizontalListAdapterCellConstraints = ListAdapterCellConstraints & WidthMeasurableView
Таким образом, внутри конкретного экрана нужно выполнить только минимальную настройку. Благодаря этому код станет проще для восприятия.Как можно увидеть из примера выше: сначала идёт блок typealias'ов для того, чтобы определить ограничения на используемые типы.DragAndDropStyle отвечает за возможность менять местами ячейки внутри коллекции.headerModel - модель, которая представляет заголовок коллекцииspacing - расстояние между элементамиДальше идёт блок замыканий, которые позволяют подписаться на определённые изменения в коллекции.Методы для подписки onReady и onResize позволяют понять, когда коллекция адаптера стала готова к работе, и когда изменился размер коллекции из-за добавления или удаления объектов, соответственно.collectionView, setup(collectionView:) - непосредственно используемый экземпляр коллекции и метод для её установкиitems - набор моделей для отображенияsetHeader - метод для установки заголовка коллекцииitemSizeCacher - класс, реализующий кеширование размеров элементов списка. Дефолтная реализация представлена ниже:
final class DefaultItemSizeCacher: UICollectionItemSizeCaching {
    private var sizeCache: [IndexPath: CGSize] = [:]
    func itemSize(cachedAt indexPath: IndexPath) -> CGSize? {
        sizeCache[indexPath]
    }
    func cache(itemSize: CGSize, at indexPath: IndexPath) {
        sizeCache[indexPath] = itemSize
    }
    func invalidateItemSizeCache(at indexPath: IndexPath) {
        sizeCache[indexPath] = nil
    }
    func invalidate() {
        sizeCache = [:]
    }
}
Остальную часть интерфейса представляют методы для обновления элементов.Также есть конкретные реализации, которые, например, заточены под определенное расположение ячеек по оси.
AnyListAdapterДо тех пор, пока мы работаем с динамическим контентом, все хорошо. Но во введении мы не зря говорили про infinite-scroll дизайн. Что делать, если в таблице нужно одновременно отображать и ячейки динамического контента(данные из сети) и статические вью? Для этого нам послужит AnyListAdapter.
public typealias AnyListSliceAdapter = ListSliceAdapter<AnyListCell>
public final class AnyListAdapter : ListAdapter<AnyListCell>, UICollectionViewDelegateFlowLayout {
    public var dimensionCalculationMode: DesignKit.AnyListAdapter.DimensionCalculationMode
    public let axis: Axis
    public init<Cell>(dynamicCellType: Cell.Type) where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.HeightMeasurableView, Cell : DesignKit.RegistrableView
    public init<Cell>(dynamicCellType: Cell.Type) where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.RegistrableView, Cell : DesignKit.WidthMeasurableView
}
public extension AnyListAdapter {
    convenience public init<C1, C2>(dynamicCellTypes: (C1.Type, C2.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.HeightMeasurableView, C1 : DesignKit.RegistrableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.HeightMeasurableView, C2 : DesignKit.RegistrableView
    convenience public init<C1, C2, C3>(dynamicCellTypes: (C1.Type, C2.Type, C3.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.HeightMeasurableView, C1 : DesignKit.RegistrableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.HeightMeasurableView, C2 : DesignKit.RegistrableView, C3 : UICollectionViewCell, C3 : DesignKit.AnimatedConfigurableView, C3 : DesignKit.HeightMeasurableView, C3 : DesignKit.RegistrableView
    convenience public init<C1, C2>(dynamicCellTypes: (C1.Type, C2.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.RegistrableView, C1 : DesignKit.WidthMeasurableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.RegistrableView, C2 : DesignKit.WidthMeasurableView
    convenience public init<C1, C2, C3>(dynamicCellTypes: (C1.Type, C2.Type, C3.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.RegistrableView, C1 : DesignKit.WidthMeasurableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.RegistrableView, C2 : DesignKit.WidthMeasurableView, C3 : UICollectionViewCell, C3 : DesignKit.AnimatedConfigurableView, C3 : DesignKit.RegistrableView, C3 : DesignKit.WidthMeasurableView
}
public extension AnyListAdapter {
    public enum Axis {
        case horizontal
        case vertical
    }
    public enum DimensionCalculationMode {
        case automatic
        case fixed(constant: CGFloat? = nil)
    }
}
Как не трудно догадаться, AnyListAdapter абстрагируется от конкретного типа ячейки. Его можно проинициализировать несколькими типами ячеек, но они все должны быть либо для горизонтального лейаута, либо вертикального. Условием здесь выступает удовлетворение протоколу HeightMeasurableView и WidthMeasurableView.
public protocol HeightMeasurableView where Self: ConfigurableView {
    static func calculateHeight(model: Model, width: CGFloat) -> CGFloat
    func measureHeight(model: Model, width: CGFloat) -> CGFloat
}
public protocol WidthMeasurableView where Self: ConfigurableView {
    static func calculateWidth(model: Model, height: CGFloat) -> CGFloat
    func measureWidth(model: Model, height: CGFloat) -> CGFloat
}
У списка так же фиксируется алгоритм подсчета высоты:
  • фиксированный(константа или статический метод расчета по модели)
  • автоматический (на основе лейаута).
Сила вся внутри ячейки-контейнера AnyListCell спрятана.
public class AnyListCell: ListAdapterCellConstraints {
    // MARK: - ConfigurableView
    public enum Model {
        case `static`(UIView)
        case `dynamic`(DynamicModel)
    }
    public func configure(model: Model, animated: Bool, completion: (() -> Void)?) {
        switch model {
        case let .static(view):
            guard !contentView.subviews.contains(view) else { return }
            clearSubviews()
            contentView.addSubview(view)
            view.layout {
                $0.pin(to: contentView)
            }
        case let .dynamic(model):
            model.configure(cell: self)
        }
        completion?()
    }
    // MARK: - RegistrableView
    public static var registrationMethod: ViewRegistrationMethod = .class
    public override func prepareForReuse() {
        super.prepareForReuse()
        clearSubviews()
    }
    private func clearSubviews() {
        contentView.subviews.forEach {
            $0.removeFromSuperview()
        }
    }
}
Такая ячейка конфигурируется двумя видами модели: статической и динамической.Первая как раз отвечает за отображение в списке обычных вью.Вторая же оборачивает в себя модель, конфигуратор и подсчет высоты, стирая при этом сам тип ячейки. В действительности отсюда и префикс в названии как ячейки, так и самого адаптера: Any.
struct DynamicModel {
    public init<Cell>(model: Cell.Model,
                    cell: Cell.Type) {
            // ...
    }
    func dequeueReusableCell(from collectionView: UICollectionView, for indexPath: IndexPath) -> UICollectionViewCell
    func configure(cell: UICollectionViewCell)
    func calcucalteDimension(otherDimension: CGFloat) -> CGFloat
    func measureDimension(otherDimension: CGFloat) -> CGFloat
}
Ниже приведён пример наполнения списка результатов поиска разного рода данными: теги, операции и плейсхолдер для индикации отсутствия элементов.
private let listAdapter = AnyListAdapter(
    dynamicCellTypes: (CommonCollectionViewCell.self, OperationCell.self)
)
func configureSearchResults(with model: OperationsSearchViewModel) {
    var items: [AnyListCell.Model] = []
    model.sections.forEach {
        let header = VerticalSectionHeaderView().configured(with: $0.header)
        items.append(.static(header))
        switch $0 {
        case .tags(nil), .operations(nil):
            items.append(
                .static(OperationsNoResultsView().configured(with: Localisation.feed_search_no_results))
            )
        case let .tags(models?):
            items.append(
                contentsOf: models.map {
                    .dynamic(.init(
                        model: $0,
                        cell: CommonCollectionViewCell.self
                    ))
                }
            )
        case .operations(let models?):
            items.append(
                contentsOf: models.map {
                    .dynamic(.init(
                        model: $0,
                        cell: OperationCell.self
                    ))
                }
            )
        }
    }
    UIView.performWithoutAnimation {
        listAdapter.deleteItemsIfNeeded(at: 0...)
        listAdapter.reloadItems(items, at: 0...)
    }
}
Таким образом, стало легко строить экраны, на которых весь контент группируется в бесконечном списке, при этом не теряя производительности переиспользования ячеек.
Список по кускамТолько что мы рассмотрели экран, который натуральным образом поделен на секции. Возникает вопрос, на сколько удобно работать с секциями в плане индексации.
Сам по себе AnyListAdapter не дает удобного решения. Очень легко наткнуться на NSInternalInconsistencyException или удалить элемент не из той секции. Поиск причины этой ошибки может занять время.Для того, чтобы обезопасить себя при работе с вставкой/удалением/обновлением элементов, мы используем концепцию слайсов по аналогии с ArraySlice, представленным в стандартной библиотеке языка Swift.Целью было сделать похожий интерфейс для работы с секциями списка изолированно, например, в своем собственном контроллере.Приведем пример сложного экрана.
let subjectsSectionHeader = SectionHeaderView(title: "Subjects")
let pocketsSectionHeader = SectionHeaderView(title: "Pockets")
let cardsSectionHeader = SectionHeaderView(title: "Cards")
let categoriesHeader = SectionHeaderView(title: "Categories")
let list = AnyListAdapter()
listAdapter.reloadItems([
    .static(subjectsSectionHeader),
    .static(pocketsSectionHeader)
    .static(cardsSectionHeader),
    .static(categoriesHeader)
])
Теперь распределим эти секции по контроллерам. Для простоты рассмотрим лишь один, так как остальные будут похожими на него.
class PocketsViewController: UIViewController {
    var listAdapter: AnyListSliceAdapter! {
        didSet {
            reload()
        }
    }
    var pocketsService = PocketsService()
    func reload() {
        pocketsService.fetch { pockets, error in
            guard let pocket = pockets else { return }
            listAdapter.reloadItems(
                pockets.map { .dynamic(.init(model: $0, cell: PocketCell.self)) },
                at: 1...
            )
        }
    }
    func didTapRemoveButton(at index: Int) {
        listAdapter.deleteItemsIfNeeded(at: index)
    }
}
let subjectsVC = PocketsViewController()
subjectsVC.listAdapter = list[1..<2]
На последней строчке мы и получаем кусок списка: в этот момент происходит определение его границ и привязка к событиям родительского списка.
public extension ListAdapter {
    subscript(range: Range<Int>) -> ListSliceAdapter<Cell> {
        .init(listAdapter: self, range: range)
    }
    init(listAdapter: ListAdapter<Cell>,
               range: Range<Int>) {
        self.listAdapter = listAdapter
        self.sliceRange = range
        let updateSliceRange: ([Int], [Int], Bool) -> Void = { [unowned self] insertions, removals, skipNextResize in
            self.handleParentListChanges(insertions: insertions, removals: removals)
            self.skipNextResize = skipNextResize
        }
        let enableWorkingWithSlice = { [weak self] in
            self?.onReady?()
            return
        }
        listAdapter.subscribe(self, onResize: updateSliceRange)
        listAdapter.subscribe(self, onReady: enableWorkingWithSlice)
    }
}
Теперь работать с секцией списка можно ничего не зная об оригинальном списке и не беспокоясь о правильности индексации.Кроме данных о рендже слайса, интерфейс слайс адаптера мало чем отличается от оригинального ListAdapter.
public final class ListSliceAdapter<Cell> : ListAdapterInput where Cell : UICollectionViewCell, Cell : ConfigurableView, Cell : RegistrableView {
    public var items: [Model] { get }
    public var onReady: (() -> Void)?
    internal private(set) var sliceRange: Range<Int> { get set }
    internal init(listAdapter: ListAdapter<Cell>, range: Range<Int>)
    convenience internal init(listAdapter: ListAdapter<Cell>, index: Int)
    public subscript(index: Int) -> Model? { get }
    public func reload(items: [Model], needsRedraw: Bool = true)
    public func insertItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)
    public func appendItem(_ item: Model, allowDynamicModification: Bool = true)
    public func deleteItem(at index: Int, allowDynamicModification: Bool = true)
    public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>)
    public func deleteItems(at indexes: [Int], allowDynamicModification: Bool = true)
    public func updateItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)
    public func reloadItems(_ newItems: [Model], at range: PartialRangeFrom<Int>, allowDynamicModification: Bool = true)
    public func reloadItems(_ newItems: [Model], at indexes: [Int], allowDynamicModification: Bool = true)
    public func reloadItems(_ newItems: [(index: Int, element: Model)], allowDynamicModification: Bool = true)
    public func moveItem(at index: Int, to newIndex: Int)
    public func performBatchUpdates(updates: () -> Void, completion: ((Bool) -> Void)?)
}
Нетрудно догадаться, что внутри проксирующих методов происходит математика индексов.
public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>) {
    guard canDelete(index: range.lowerBound) else { return }
    let start = globalIndex(of: range.lowerBound)
    let end = sliceRange.upperBound - 1
    listAdapter.deleteItems(at: Array(start...end))
}
При этом ключевую роль играет поддержка кусков внутри самого ListAdapter.
public class ListAdapter {
    // ...
    var resizeSubscribers = NSMapTable<AnyObject, NSObjectWrapper<ResizeCallback>>.weakToStrongObjects()
}
extension ListAdapter {
    public func appendItem(_ item: Model) {
        let index = items.count
        let changes = {
            self.items.append(item)
            self.handleSizeChange(insert: self.items.endIndex)
            self.collectionView?.insertItems(at: [IndexPath(item: index, section: 0)])
        }
        if #available(iOS 13, *) {
            changes()
        } else {
            performBatchUpdates(updates: changes, completion: nil)
        }
     }
    func handleSizeChange(removal index: Int) {
        notifyAboutResize(removals: [index])
    }
    func handleSizeChange(insert index: Int) {
        notifyAboutResize(insertions: [index])
    }
    func notifyAboutResize(insertions: [Int] = [], removals: [Int] = [], skipNextResize: Bool = false) {
        resizeSubscribers
            .objectEnumerator()?
            .allObjects
            .forEach {
                ($0 as? NSObjectWrapper<ResizeCallback>)?.object(insertions, removals, skipNextResize)
            }
    }
    func shiftSubscribers(after index: Int, by shiftCount: Int) {
        guard shiftCount > 0 else { return }
        notifyAboutResize(
            insertions: Array(repeating: index, count: shiftCount),
            skipNextResize: true
        )
    }
}
То есть при каждом добавлении и удалении элемента из исходного списка мы уведомляем всех подписчиков твиттера совета джедаев об изменении размера коллекции.ВыводыНе помешает убедиться, что все это было не зря, поэтому освежим в памяти полученные бенифиты. Во-первых, мы получили единый интерфейс для разных видов списков. В том числе с разным лейаутом: горизонтальный и вертикальный. Если под капотом нас вдруг не устроит производительность (или баги новой iOS) у UICollectionView, то легко сможем поддержать тот же протокол и для таблиц.И, что для ленивых самое важное - сетап экрана со списком занимает меньше 10 строк кода.Если мы раньше боялись усложнять экран работой с таблицей для отображения разнородных данных, то сейчас смело пишем каждый третий экран( ~30%) на списках, вооружившись одним из нашего обширного арсенала адаптеров. А если хотите в модульную декомпозицию - то к вашим услугам адаптеры для куска списка.Теперь вы с легкостью найдете любимый фрукт джогон в удобном поиске приложения доставки еды на Татуине, а если вы джедай - то без задержек доскролите до конца список заданий от магистра Йоды.
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_razrabotka_pod_ios (Разработка под iOS), #_razrabotka_mobilnyh_prilozhenij (Разработка мобильных приложений), #_swift, #_swift, #_uikit, #_ios, #_uicollectionview, #_uitableview, #_spiski (списки), #_pattern_adapter (паттерн адаптер), #_blog_kompanii_vivid_money (
Блог компании Vivid Money
)
, #_razrabotka_pod_ios (
Разработка под iOS
)
, #_razrabotka_mobilnyh_prilozhenij (
Разработка мобильных приложений
)
, #_swift
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 19:06
Часовой пояс: UTC + 5