Многие сталкивались с проблемой реализации красивого отображения кнопок для UITableViewCell при сдвиге влево. Некоторые использовали стандартный функционал «из коробки», другие заморачивались над собственной реализацией, а кто-то обошелся Unicode-символами. В этой статье я расскажу как добиться максимальной кастомизации UITableViewRowAction.

Сперва немного теории: UITableViewRowAction – это класс, позволяющий пользователю при сдвиге ячейки влево выполнить определенные действия с данной ячейкой (такие как «удалить», «изменить» и т. п.). Каждый экземпляр этого класса представляет одно действие для выполнения и включает в себя текст, стиль и обработчик нажатия. iOS SDK позволяет нам настроить UITableViewRowAction следующим образом:

public convenience init(style: UITableViewRowActionStyle, title: String?, handler: @escaping (UITableViewRowAction, IndexPath) -> Swift.Void)

Также можно задать backgroundColor.

Существующие способы.


Рассмотрим существующие примеры реализации, или то, что я нашел на просторах интернета.
Способ №1. Использовать функционал «из коробки». Этого достаточно для того, чтобы использовать обычные кнопки с текстом и фоном. Выглядит это слишком просто и скучно:


Способ №2. Задавать backgroundColor с помощью UIColor(patternImage: UIImage). Это позволит добиться красоты, но лишит универсальности, добавит ряд ограничений и сложностей. Так как ширина UITableViewRowAction высчитывается по размеру текста, то нужно задавать title как набор пробельных символов определенной ширины. Мы не сможем использовать этот способ для ячеек с динамической высотой. Если ячейка увеличится в высоте, то будет выглядеть примерно так:



Способ №3. Использовать Unicode-символы. Здесь не нужно высчитывать размер текста пробелами и этот способ подходит для ячеек с динамической высотой. Выглядит симпатично:



Но если мы захотим сделать текст под иконкой, этот способ нам не подойдет.

Способ №4. Использовать CoreGraphics для отрисовки UI компонентов. Идея состоит в том, чтобы вручную отрисовать изображение с нужным расположением элементов (текста и картинки). При этом нужно высчитывать размеры и координаты для расположения каждого элемента. Используя этот способ, мы можем сделать и текст, и картинку, расположив их как нам нужно. Но этот способ сложный в реализации. Нам хочется чего-то попроще.

Другие реализации. Есть еще несколько готовых решений. В них используется собственная реализация ячейки с добавлением ScrollView и другой логики. Например, SwipeCellKit или MGSwipeTableCell. Но эти решения не позволяют добиться стандартного поведения iOS, они охватывают только одну ячейку, в отличие от UITableViewRowAction, которое создается в делегате таблицы. Например, используя одно из этих решений, если открыть действия при сдвиге влево для нескольких ячеек, в каждой из них придется вручную закрывать эти действия, что крайне неудобно. Для этого приходится дописывать свою логику для контроля открытых действий по свайпу. Мы же хотим создать простое решение, не требующее дополнительных затрат, как будто мы используем стандартную реализацию UITableViewRowAction.

Приступим


Попробуем создать универсальное решение, позволяющее сделать UITableViewRowAction с любым дизайном, который нам нужен.

Создадим наш класс-наследник от UITableViewRowAction и определим основной метод создания экземпляра:

typealias RowActionTapHandler = (RowAction, IndexPath) -> Void
 
class RowAction: UITableViewRowAction {

    static func rowAction(with actionHandler: @escaping RowActionTapHandler) -> Self {
        let rowAction = self.init(style: .default, title: nil) { action, indexPath in 
            guard let action = action as? RowAction else { return } 

            actionHandler(action, indexPath)
        }

        return rowAction
    }
}

Так как UITableViewRowAction не имеет основного инициализатора, то для удобства мы можем сделать статичный метод, где внутри создадим UITableViewRowAction стандартным способом и прокинем в него actionHandler для обработки нажатия.

Теперь попробуем добиться универсальной кастомизации кнопки UITableViewRowAction. Идея заключается в том, чтобы получать внешний вид кнопки из готового UIView (которое можно настроить в обычной xib-ке). Так мы сможем сделать кнопку любого вида, исходя из индивидуальных предпочтений (любой шрифт текста, размер и расположение элементов и т. п.).

У нас есть две вещи, которые мы можем поменять в UITableViewRowAction: backgroundColor и title. На данный момент единственный способ для изменения внешнего вида кнопки UITableViewRowAction – это изменение свойства backgroundColor. Следовательно, нашей целью является преобразование: UIView -> UIImage -> UIColor. Получить цвет мы можем с помощью изображения следующим образом: UIColor(patternImage: UIImage). А изображение мы можем получить из UIView, отрисовав его с помощью стандартного фреймворка CoreGraphics.

Но сначала определимся, как нам задавать размеры кнопки: «из коробки» нет этой функциональности, и размер в UITableViewRowAction высчитывается по размеру текста поля title.

После проведения экспериментов (измерения вычисленной и реальной ширины кнопки) я нашел пробельный Unicode-символ, который имеет наименьшую ненулевую ширину: U+200A

Будем его использовать и создадим константу:

private enum Constants {
    static let placeholderSymbol = "\u{200A}"
}

Напишем функцию для получения строки из пробельных символов, соответствующей нужному нам размеру:

private static let placeholderSymbolWidth: CGFloat = {
    let flt_max = CGFloat.greatestFiniteMagnitude
    let maxSize = CGSize(width: flt_max, height: flt_max)
    let attributes = [NSFontAttributeName : UIFont.systemFont(ofSize: UIFont.systemFontSize)]
    let boundingRect = Constants.placeholderSymbol.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, attributes: attributes, context: nil)

    return boundingRect.width
}()

private func emptyTitle(for size: CGSize) -> String {
    var usefulWidth = size.width - Constants.minimalActionWidth
    usefulWidth = usefulWidth < 0 ? 0 : usefulWidth
    let countOfSymbols = Int(floor(usefulWidth * Constants.shiftFactor / RowAction.placeholderSymbolWidth))
        
    return String(repeating: Constants.placeholderSymbol, count: countOfSymbols)
}

Переменная placeholderSymbolWidth получает ширину нашего Unicode-символа, а функция emptyTitle(for size: CGSize) -> String вычисляет количество пробельных символов, соответствующих размеру size и возвращает строку из этих символов. В приведенной формуле используется константа shiftFactor. Это коэффициент отклонения ширины от номинального значения. Дело в том, что при увеличении ширины UITableViewRowAction растет небольшая погрешность (т.е. фактическая ширина кнопки отличается от вычисленной ширины по обычной формуле). Чтобы избежать этого, мы используем эту константу, выведенную экспериментальным путем.

Также UITableViewRowAction имеет минимальную ширину – 30 (Например, если задать title = ""). Эта ширина является минимально необходимой для отступов слева и справа от заголовка.
Обновим наши константы:

private enum Constants {
    static let placeholderSymbol = "\u{200A}"
    static let minimalActionWidth: CGFloat = 30
    static let shiftFactor: CGFloat = 1.1
}

Теперь приступим к получению изображения из UIView. Напишем следующую функцию:

private func image(from view: UIView) -> UIImage? {        
    UIGraphicsBeginImageContextWithOptions(view.bounds.size, view.isOpaque, UIScreen.main.scale)
    guard let context = UIGraphicsGetCurrentContext() else {
        assertionFailure("Something wrong with CoreGraphics image context");
        return nil
    }
        
    context.setFillColor(UIColor.white.cgColor)
    context.fill(CGRect(origin: .zero, size: view.bounds.size))
        
    view.layer.render(in: context)
    let img = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()
        
    return img
}

Эта функция преобразовывает наше view в картинку. При этом размеры картинки соответствуют размерам view.

Остался последний шаг, где мы реализуем метод для настройки UITableViewRowAction с помощью UIView:

func setupForCell(with view: UIView) {
    guard let img = image(from: view) else { return }
        
    self.title = emptyTitle(for: img.size)
        
    self.backgroundColor = UIColor(patternImage: img)
}

Здесь мы используем написанные ранее функции для конфигурации нашей кнопки и задаем backgroundColor с помощью полученного изображения.

Вот собственно и все. Мы получили универсальное решение для UITableViewRowAction и теперь можем настраивать ее любым образом с помощью UIView.



Начиная с версии iOS 11 Apple добавила новый класс для действия по свайпу для ячеек в таблице — UIContextualAction. Он позволяет сразу задать изображение, которое будет отображаться на действии по свайпу(это то, чего не хватало нам все эти годы использования UITableViewRowAction). Он должен был облегчить нам жизнь, но помимо новой возможности, создал дополнительные проблемы. Этот класс накладывает ограничения на размер картинки, не позволяет использовать одновременно текст и картинку, и также, картинка должна иметь специальный формат(template image). Поэтому я доработал мое решение для поддержки действий по свайпу в iOS 11. Теперь для того чтобы создать UITableViewRowAction или UIContextualAction нужно сперва создать фабрику RowActionFactory, настроить ее с помощью нашего view (как мы делали это выше в методе setupForCell(with view: UIView)) и вернуть нужную нам сущность: UITableViewRowAction или UIContextualAction (используя методы rowAction() или contextualAction(for indexPath: IndexPath) соответственно). Ниже представлен пример использования RowActionFactory:

@available(iOS 11.0, *)
    func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {        
        var contextualActions: [UIContextualAction] = []
        for actionType in rowActionTypes { // actionType - тип действия по свайпу
            let rowActionFactory = RowActionFactory { (indexPath) in
                tableView.setEditing(false, animated: true)
            }
            let rowActionView = <...> // создание UIView для действия по свайпу
            rowActionFactory.setupForCell(with: rowActionView)
            if let contextualAction = rowActionFactory.contextualAction(for: indexPath) {
                contextualActions.append(contextualAction)
            }
        }
        let swipeActionsConfiguration = UISwipeActionsConfiguration(actions: contextualActions)
        swipeActionsConfiguration.performsFirstActionWithFullSwipe = false
        
        return swipeActionsConfiguration
    }

Готовое решение можно посмотреть здесь: TCSCustomRowActionFactory(github.com).

Комментарии (5)


  1. CRivlaldo
    04.07.2018 07:25

    Способо №3 – это прямой путь к реджекту. Ревьюеры могут ссылаться на пункт 5.2.5 гайдлайнов. Хотя в них нет четких указаний, но использовать эмоджи там, где их не вводил пользователь, слишком рискованно.


    1. ad1Dima
      04.07.2018 10:40

      Там имеется ввиду логотип Apple: emojipedia.org/apple-logo


      1. CRivlaldo
        04.07.2018 11:41

        Про App Store Review Guidelines я, пожалуй, зря упоминул.

        Есть конкретные примеры, например, этот, где описывается, что реджектили приложение с emoji в UI.

        Причина – нарушение копирайта. Об этом также писали в твиттере.


        1. ad1Dima
          04.07.2018 11:51

          Это один и тот же пример. Кроме его слов, что по телефону ему уточнили, что эмоджи нельзя, там нет ничего. И он ссылается на тот же пункт.

          Больше похоже что тут либо перегиб какого-то ревьювера, либо недопонимание со стороны автора приложения.

          UPD. Более того на скриншотах его приложения, дважды с тех пор обновившихся, эмоджи есть. t.co/5i7U0FAt8Z


        1. a_boy
          04.07.2018 13:47

          emojis are back:



          4.5.6
          теперь опять можно их выводить, без ввода юзером