Динамическое изменение конфигурации кнопки в iOS 15

30 августа 2021

В этом туториале мы научимся изменять параметры конфигурации кнопки при внутренних изменениях состояния (например, при выборе кнопки или ее подсвечивании) и при внешних изменениях состояния  (напр. воздействий других модулей в рамках установленной бизнес-логики). Мы увидим, как этот новый подход работает со старыми методами, таким как setTitle(_:for:). Может ли он заменить старые решения и могут ли оба подхода работать бок о бок? Давайте выясним.

В старом API кнопка могла изменять свой внешний вид и содержание на основании своего текущего состояния. Ниже приведены несколько примеров:

button.setTitle("Normal", for: .normal)
button.setTitle("Selected", for: .selected)
button.setBackgroundImage(UIImage(named: "foo"), for: .normal)

Вместе с новым API конфигурации, кнопки также получили новые методы подстройки параметров с учетом изменения состояния кнопки. Помимо этого, новый API облегчает процесс адаптации кнопки к внешним изменениям состояния (например, изменения и взаимодействия с другими компонентами кода в соответствии с вашей моделью бизнес-логики).

Я разделю изменения состояния кнопки на две группы:

1. Внутренние изменения состояния, такие как выбор или выделение (состояния selected или highlighted);
2. Внешние изменения состояния. Примером может служить название кнопки (title), которое отражает число предметов в корзине покупок.

Внутренние изменения состояния

Внутреннее изменение состояние относится к любым изменениям состояния самой кнопки, таким как выделение (highlighted), выбор (selected) или отключение (disabled).

Все параметры конфигурации, которые мы обсуждали в статье «A new way to style UIButton with UIButton.Configuration in iOS 15», не зависят ни от одного состояния кнопки. Мы просто установили их и забыли. Хотя этого в большинстве случаев достаточно, у нас всегда есть возможность их изменить.
Раньше мы могли указать некоторые параметры в методах задания свойств, зависящих от состояний:

func setTitle(_ title: String?, for state: UIControl.State)
func titleColor(for: UIControl.State) -> UIColor?
func setBackgroundImage(UIImage?, for: UIControl.State)

С выходом UIButton.Configuration Apple внедрила новый, более гибкий подход, по сравнению с API прошлой версии. Кнопка теперь имеет специальное место в структуре для обновления параметров конфигурации через новое свойство, configurationUpdateHandler.

Configuration Update Handler

UIButton.ConfigurationUpdateHandler – это функция замыкания, обновляющая конфигурации кнопки.

typealias ConfigurationUpdateHandler = (UIButton) -> Void

Мы передаем UIButton.ConfigurationUpdateHandler новому свойству кнопки configurationUpdateHandler, которое вызовет выполнение функции замыкания, когда состояние кнопки изменится.

var configurationUpdateHandler: UIButton.ConfigurationUpdateHandler? { get set }

Ниже приведен пример, в котором название кнопки изменяется при изменении её состояния:

var configuration = UIButton.Configuration.filled()
configuration.baseBackgroundColor = UIColor.systemPink
configuration.buttonSize = .large

let handler: UIButton.ConfigurationUpdateHandler = { button in // 1
    switch button.state { // 2
    case [.selected, .highlighted]:
        button.configuration?.title = "Highlighted Selected"
    case .selected:
        button.configuration?.title = "Selected"
    case .highlighted:
        button.configuration?.title = "Highlighted"
    case .disabled:
        button.configuration?.title = "Disabled"
    default:
        button.configuration?.title = "Normal"
    }
}

let button = UIButton(configuration: configuration, primaryAction: nil)
button.configurationUpdateHandler = handler // 3

let selectedButton = UIButton(configuration: configuration, primaryAction: nil)
selectedButton.isSelected = true
selectedButton.configurationUpdateHandler = handler // 4

let disabledButton = UIButton(configuration: configuration, primaryAction: nil)
disabledButton.isEnabled = false
disabledButton.configurationUpdateHandler = handler // 5 

1. Модуль обновления вызывается каждый раз при изменении состояния кнопки.

2. Мы устанавливаем варианты изменения названия кнопки, привязанные к button.state. Мы можем указать в условии как одной состояние, так и комбинацию состояний, что было сделано в .setTitle("Highlighted Selected", for: [.selected, .highlighted]).

3-5. Такой же конструктор мы можем использовать для всех кнопок.

Кнопки могут изменять свои параметры отображения (configuration) в зависимости от состояния кнопки (button state).

Заменяемый старый API

configurationUpdateHandler может заменить большую часть методов задания свойств, зависящих от состояний.

func setTitle(_ title: String?, for state: UIControl.State)

setTitle может быть заменен следующей конструкцией обновления состояний.

// Old API
button.setTitle("Normal", for: .normal)
button.setTitle("Highlighted", for: .highlighted)

// New API
button.configurationUpdateHandler = { button in
    switch button.state {
    case .highlighted:
        button.configuration?.title = "Highlighted"
    default:
        button.configuration?.title = "Normal"
    }
}
func setAttributedTitle(_ title: NSAttributedString?, for state: UIControl.State)

setAttributedTitle может быть заменен следующей конструкцией обновления состояний.

// Old API
let button = UIButton(configuration: configuration, primaryAction: nil)

let attributes: [NSAttributedString.Key: Any] = [
    .underlineStyle: NSUnderlineStyle.single.rawValue
]

let string = NSAttributedString(
    string: "Underline",
    attributes: attributes)

button.setAttributedTitle(string, for: .highlighted)
button.setTitle("Normal", for: .normal)

// New API
var container = AttributeContainer()
container.underlineStyle = .single

let button2 = UIButton(configuration: configuration, primaryAction: nil)
button2.configurationUpdateHandler = { button in
    switch button.state {
    case .highlighted:
        button.configuration?.attributedTitle = AttributedString("Underline", attributes: container)
    default:
        button.configuration?.title = "Normal"
    }
}

Как вы можете видеть, результат использования обеих версий API идентичен.

Атрибут названия кнопки адаптируется под изменение состояния кнопки.

iOS 15 добавляет новый метод AttributedString, который является улучшенной версией NSAttributedString. AttributeContainer – это контейнер для ключей и значений атрибутов, который используется AttributedString.

Отсутствующий старый API

Некоторые сеттеры свойств недоступны в новом API например, func setTitleShadowColor(UIColor?, for: UIControl.State), func setBackgroundImage(UIImage?, for: UIControl.State). У нас нет возможности изменять эти свойства в новом API, и попытки задать их ни к чему бы не привели.

В следующем примере setBackgroundImage и setTitleShadowColor не окажут никакого воздействия на кнопку:

var configuration = UIButton.Configuration.filled()
configuration.baseBackgroundColor = UIColor.systemPink
configuration.buttonSize = .large

let button = UIButton(configuration: configuration, primaryAction: nil)
button.setTitle("Normal", for: .normal)
let attributes: [NSAttributedString.Key: Any] = [
    .underlineStyle: NSUnderlineStyle.single.rawValue
]

let string = NSAttributedString(
    string: "Underline",
    attributes: attributes)

// The following setter take no effect.
button.setBackgroundImage(UIImage(systemName: "scribble.variable"), for: .highlighted)
button.setTitleShadowColor(UIColor.systemPink, for: .highlighted)
button.titleLabel?.shadowOffset = CGSize(width: 5, height: 5)

Старое против нового

В предыдущих разделах мы увидели, что использование старого API для кнопки, созданной новым инициализатором, возможно. В этот разделе мы изучим ограничения и последствия использования старого/нового API с кнопкой, созданной старыми/новыми методами.

Старая кнопка

Посмотрим, как «старая» кнопка реагирует на применение к ней нового API.

Нулевая конфигурация

Кнопка, созданная через старый API, не содержит конфигурационных параметров (button.configuration равен nil), поэтому изменение конфигурации результата не приносит.

let button = UIButton(type: .system)
        
var container = AttributeContainer()
container.underlineStyle = .single

print(button.configuration)
// nil

button.setTitle("Old", for: .normal)
button.configuration?.title = "New" // 1

В этом примере установленный button.configuration? .title никогда не будет заменять тот, который установлен с setTitle, поскольку button.configuration равен нулю.

Название кнопки использует значение, полученное методом setTitle.

Ошибка выполнения (Run-time error)

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

Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid parameter not satisfying: configuration != nil'

let button = UIButton(type: .system)
        
var container = AttributeContainer()
container.underlineStyle = .single

button.setTitle("Old", for: .normal)

button.configurationUpdateHandler = { button in // 1
    
}

1. Старая кнопка не поддерживает новый API.

Новая кнопка

Самая интересная часть. Что произойдет, если мы попробуем обновить параметры конфигурации «новой» кнопки, используя старые и новые методы одновременно.

Старый сеттер переопределяет значения параметров конфигурации

Старый метод сеттера будет иметь приоритет над новым API конфигурации кнопок.

В нижеуказанном примере мы зададим различные названия кнопки старым и новым методами соответственно:

var configuration = UIButton.Configuration.filled()
configuration.baseBackgroundColor = UIColor.systemPink
configuration.buttonSize = .large

let button = UIButton(configuration: configuration, primaryAction: nil)

button.configuration?.title = "New"
button.setTitle("Old", for: .normal)

Порядок присвоения имени кнопки не имеет значения, результат будет тем же. Кнопка будет называться “Old”.

button.setTitle("Old", for: .normal)
button.configuration?.title = "New"

// Both yield the same result
button.configuration?.title = "New"
button.setTitle("Old", for: .normal)
Название, установленное методом setTitle, всегда будет использовано в первую очередь.

configurationUpdateHandler переопределяет всё

Любое значение, заданное через конфигуратор, будет иметь приоритет на всеми значениями, установленными вне этого замыкания.

var configuration = UIButton.Configuration.filled()
configuration.baseBackgroundColor = UIColor.systemPink
configuration.buttonSize = .large

let button = UIButton(configuration: configuration, primaryAction: nil)

button.setTitle("Old", for: .normal)
button.configuration?.title = "New"

button.configurationUpdateHandler = { button in

    switch button.state {
    case .highlighted:
        button.configuration?.title = "New Highlighted" // 1
    default:
        break
    }
}
button.setTitle("Old Highlighted", for: .highlighted) // 2

Выделяемое (highlighted) название было задано в configurationUpdateHandler в пункте 1 имеющий приоритет выше, чем setTitle в пункте 2.

Название, переданное в configurationUpdateHandler используется для отображения «выделенного» состояния

Задание названия кнопки для состояния default также переопределяет всё, что лежит за пределами configurationUpdateHandler, т.е. имеет приоритет над всем, что не входит в конфигуратор.

button.configurationUpdateHandler = { button in
    switch button.state {
    case .highlighted:
        button.configuration?.title = "New Highlighted"
    default:
        button.configuration?.title = "New Normal"
    }
}
Название кнопки из configurationUpdateHandler используются как для нормального, так и для выделенного (highlighted) состояния.

Примечание

Имеется один интересный аспект в предыдущем примере кода. Если мы зададим название кнопки через конфигуратор configurationUpdateHandler только для выделенного состояния, то название кнопки для нормального состояния будет взято программой из метода setTitle:

var configuration = UIButton.Configuration.filled()
configuration.baseBackgroundColor = UIColor.systemPink
configuration.buttonSize = .large

let button = UIButton(configuration: configuration, primaryAction: nil)

button.setTitle("Old", for: .normal)
button.configuration?.title = "New"

button.configurationUpdateHandler = { button in
    switch button.state {
    case .highlighted:
        button.configuration?.title = "New Highlighted"
    default:
        break // 1
    }
}
button.setTitle("Old Highlighted", for: .highlighted)

1. Оставляем все остальные состояния кнопки пустыми.

Кнопка использует нормальное название (normal title), установленное в setTitle, в качестве названия для обычного, невыделенного состояния (fallback).

Название нормального состояния ведет себя таким образом только при использовании старых сеттеров. Если вы зададите название через button.configuration?.title, оно не будет работать так и название всегда будет "New Highlighted".

var configuration = UIButton.Configuration.filled()
configuration.baseBackgroundColor = UIColor.systemPink
configuration.buttonSize = .large

let handler: UIButton.ConfigurationUpdateHandler = { button in
 
    switch button.state {
    case .highlighted:
        button.configuration?.title = "New Highlighted"
    default:
        break // 1
    }
}

let button = UIButton(configuration: configuration, primaryAction: nil)
button.setTitle("Old", for: .normal) // 2
button.configurationUpdateHandler = handler

let button2 = UIButton(configuration: configuration, primaryAction: nil)
button2.configuration?.title = "New" // 3
button2.configurationUpdateHandler = handler

1. Мы не задаем никакого названия для обычного состояния (default state).

2. button задает title через старый API, setTitle.

3. button2 задает title через новый API, button2.configuration?.title = "New".

button будет использовать название из setTitle для нормального состояния, в то время как button2 начнет с "New", но навсегда изменится на "New Highlighted" после нажатия.

 

Только названия, заданные старыми сеттерами метода, будут работать как названия нормального состояния.

Поэтому вам следует всегда определять значения для каждого состояния и никогда не оставлять состояние по умолчанию пустым при использовании configurationUpdateHandler.

Внешние изменения состояния

Всё, что бы было изложено в статье до сих пор – это воспроизведение старого функционала средствами нового API. Но не менее важным нововведением свежей версии API стало значительное облегчение настройки реагирования кнопки на внешние изменения состояния.

Когда я говорю о внешних изменениях состояния, я подразумеваю любые изменения параметров оформления кнопки, не связанные с воздействиями «изнутри» самой кнопки, например, заголовок кнопки оформления заказа, которая изменяется при добавлении предметов в корзину, или состояние кнопки лайк/дизлайк. Другими словами, кнопка изменяется под воздействием других компонентов и модулей программы, т.е. изменения основаны на бизнес-логике продукта.

Для работы кнопки подобных образом должны быть выполнены два условия:

1. Мы должны сообщить кнопке, что необходимо обновлять конфигурацию при изменении внешнего состояния.
2. Мы должны указать, как непосредственно будет изменяться кнопка.

Как сообщить кнопке, что нужно обновлять

Так как внешним состояние может являться любой параметр, кнопка сама по себе не может знать, что именно будет влиять на её внешний вид. Необходимо передать кнопке, что внешнее состояние было изменено. Сделать это можно, используя новый метод кнопки setNeedsUpdateConfiguration().
setNeedsUpdateConfiguration() сообщает системе, что нужно обновить конфигурацию кнопки. Система выполнит обновление, как только получит setNeedsUpdateConfiguration, но не всегда она получает его немедленно. Если вызвать этот метод множество раз перед тем, как у системы появится возможность выполнить обновление кнопки, она может объединить несколько запросов в один и выполнить единственное обновление.

Ниже приведен пример обновления кнопки оформления заказа при изменении свойства itemCount

var checkoutButton: UIButton = {
    var configuration = UIButton.Configuration.filled()
    configuration.baseBackgroundColor = UIColor.systemPink
    configuration.buttonSize = .large
    
    return UIButton(configuration: configuration, primaryAction: nil)
}()

private var itemCount: Int = 0 {
    didSet { // 1
        checkoutButton.setNeedsUpdateConfiguration() // 2
    }
}

1. Когда интересующее нас значение изменяется (didSet), будет вызвана setNeedsUpdateConfiguration.

2. Вызов setNeedsUpdateConfiguration() для кнопки, которую мы хотим обновить.

Обновление кнопки после изменения состояния

Способ обновление кнопки аналогичен тому, что мы делали раннее используя configurationUpdateHandler.

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

private var itemCount: Int = 0 {
    didSet {
        checkoutButton.setNeedsUpdateConfiguration() // 1
    }
}

var checkoutButton: UIButton = {
    var configuration = UIButton.Configuration.filled()
    configuration.baseBackgroundColor = UIColor.systemPink
    configuration.buttonSize = .large
    
    return UIButton(configuration: configuration, primaryAction: nil)
}()

override func viewDidLoad() {
    super.viewDidLoad()

    let addButton = UIButton(configuration: .gray(), primaryAction: UIAction(handler: { [unowned self] _ in
        itemCount += 1
    }))

    addButton.setTitle("Add Item", for: .normal)
    
    checkoutButton.configurationUpdateHandler = { [unowned self] button in
        button.configuration?.title = "Checkout \(itemCount)" // 2
    }

    ...
}

1. Изменения itemCount вызовут обновление кнопки.

2. Зададим configurationUpdateHandler чтобы обновить конфигурацию кнопки с использованием значения itemCount.

Кнопка изменяет свое название в зависимости от свойств счетчика предметов

Заключение

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

Оригинал статьи

Содержание