feature/stateful_button_improvements #12

Merged
ivan.smolin merged 8 commits from feature/stateful_button_improvements into master 2023-07-24 10:00:38 +03:00
Member
  • Кнопка со спиннером теперь корректно работает и до и после iOS 15
  • Операции отрисовки на CoreGraphics перенесены в отдельный модуль TICoreGraphicsUtils с документацией и примерами в playground

StatefulButton configuration example:


// ViewModel

private static func createFuelingButtonViewModel() -> DefaultConfigurableStatefulButton.ViewModel {
    let refuelImage = Asset.Station.Traits.fuelingStationRefuelDefault.image

    let renderer = UIGraphicsImageRenderer(size: refuelImage.size)

    let disabledImage = renderer.image {
        guard let cgImage = refuelImage.cgImage else {
            return
        }

        TemplateDrawingOperation(image: cgImage,
                                 imageSize: refuelImage.size,
                                 color: Asset.Station.refuelDisabledColor.color.cgColor)
        .apply(in: $0.cgContext)
    }

    return DefaultConfigurableStatefulButton.ViewModel(stateViewModelMap: [
        .normal: .init(title: "Заправиться",
                       image: refuelImage),
        .loading: .init(title: "Заправиться",
                        image: nil),
        .disabled: .init(title: "Заправиться",
                         image: disabledImage)
    ],
                                                       currentState: .loading)
}

// Appearance

static var refuelButtonAppearance: RefuelRow.Appearance {
    .make {
        $0.subviewAppearance {
            $0.set(appearanceBuilder: {
                $0.textAttributes = .smallActionButtonDefault
                $0.contentLayout.titleInsets = .horizontal(left: 8)
                $0.border.roundedCorners = .allCorners
                $0.border.cornerRadius = 4
            },
                   for: [.normal, .loading, .disabled, .highlighted])

            if let normalAppearance = $0.stateAppearances[.normal] {
                normalAppearance.backgroundColor = Asset.Common.mainRedColor.color.withAlphaComponent(0.1)
            }

            if let loadingAppearance = $0.stateAppearances[.loading] {
                loadingAppearance.backgroundColor = Asset.Common.disabledLightGrayColor.color.withAlphaComponent(0.3)
                loadingAppearance.textAttributes = .smallActionButtonDisabled
            }

            if let disabledAppearance = $0.stateAppearances[.disabled] {
                disabledAppearance.backgroundColor = Asset.Common.disabledLightGrayColor.color.withAlphaComponent(0.3)
                disabledAppearance.textAttributes = .smallActionButtonDisabled
            }

            if let highlightedAppearance = $0.stateAppearances[.highlighted] {
                highlightedAppearance.backgroundColor = Asset.Common.mainRedColor.color.withAlphaComponent(0.2)
            }

            $0.activityIndicatorPosition = .beforeTitle(padding: 8)

            $0.layout {
                $0.insets = .horizontal(16).vertical(top: 16, bottom: 0)
                $0.size = .fixedHeight(48)
            }
        }
    }
}

// Configuration

static func buildRefuelRow(dataSource: FuelingStationInfoViewPresenter) -> RefuelRow {
    RefuelRow(item: dataSource.fuelingButtonViewModel)
        .with(appearance: Self.refuelButtonAppearance)
}

DrawingOperation - протокол для инкапсулирования низкоуровневых вызовов отрисовки CoreGraphicss.

Позволяет:

  • сгруппировать низкоуровневые операции CoreGraphics в более высокоуровневые
  • использовать композицию из высокоуровневых операций
  • получить размер изображения необходимый для создания контекста

Базовые операции

"Из коробки" доступны самые часто испольуемые операции.

SolidFillDrawingOperation

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

import TICoreGraphicsUtils
import UIKit

let solidFillSize = CGSize(width: 200, height: 200)

let renderer = UIGraphicsImageRenderer(size: solidFillSize)
let solidFillDrawingOperation = SolidFillDrawingOperation(color: UIColor.purple.cgColor,
                                                          rect: CGRect(origin: .zero,
                                                                       size: solidFillSize))

let solidFillImage = renderer.image {
    solidFillDrawingOperation.apply(in: $0.cgContext)
}

BorderDrawingOperation

Операция создаёт рамку определённой формы и размера.

let borderDrawingOperation = BorderDrawingOperation(frameableContentRect: CGRect(origin: .zero,
                                                                                 size: solidFillSize),
                                                    border: 2,
                                                    color: UIColor.red.cgColor,
                                                    radius: 4,
                                                    exteriorBorder: false)

let borderImage = renderer.image {
    borderDrawingOperation.apply(in: $0.cgContext)
}

CALayerDrawingOperation

Операция отрисовывает содержимое CALayer в изображение. Обычно используется для снапшота UIView.

let button = UIButton(type: .custom)
button.setTitle("This is button", for: .normal)
button.setBackgroundImage(borderImage, for: .normal)

button.sizeToFit()

let layerDrawingOperation = CALayerDrawingOperation(layer: button.layer, offset: .zero)

let layerRenderer = UIGraphicsImageRenderer(size: button.bounds.size)

let buttonSnapshotImage = layerRenderer.image {
    layerDrawingOperation.apply(in: $0.cgContext)
}

TextDrawingOperation

Операция отрисовывает текст с заданными атрибутами

let textDrawingOperaton = TextDrawingOperation(text: "This is string",
                                               textAttributes: [
                                                .font: UIFont.boldSystemFont(ofSize: 15),
                                                .foregroundColor: UIColor.white
                                               ])

let textRenderer = UIGraphicsImageRenderer(size: textDrawingOperaton.desiredContextSize)

let textImage = textRenderer.image {
    textDrawingOperaton.apply(in: $0.cgContext)
}

TemplateDrawingOperation

Операция заменяет все цвета кроме прозрачного на переданный цвет.
Используется для приданиям иконкам определённого цвета (аналог tintColor).

if let cgImage = textImage.cgImage {
    let templateDrawingOperation = TemplateDrawingOperation(image: cgImage,
                                                            imageSize: textImage.size,
                                                            color: UIColor.red.cgColor)

    let tintedImage = textRenderer.image {
        templateDrawingOperation.apply(in: $0.cgContext)
    }
}

TransformDrawingOperation

Операция производит изменение размера изображения учитывая пропорции
и другие настройки (по аналогии с contentMode у UIImage).

if let cgImage = textImage.cgImage {
    let newSize = CGSize(width: textImage.size.width / 1.5, height: textImage.size.height)
    let resizeOperation = TransformDrawingOperation(image: cgImage,
                                                    imageSize: textImage.size,
                                                    maxNewSize: newSize,
                                                    resizeMode: .scaleAspectFill)

    let resizeRenderer = UIGraphicsImageRenderer(size: newSize)

    let resizedImage = resizeRenderer.image {
        resizeOperation.apply(in: $0.cgContext)
    }
}
- Кнопка со спиннером теперь корректно работает и до и после iOS 15 - Операции отрисовки на CoreGraphics перенесены в отдельный модуль TICoreGraphicsUtils с документацией и примерами в playground # StatefulButton configuration example: ```swift // ViewModel private static func createFuelingButtonViewModel() -> DefaultConfigurableStatefulButton.ViewModel { let refuelImage = Asset.Station.Traits.fuelingStationRefuelDefault.image let renderer = UIGraphicsImageRenderer(size: refuelImage.size) let disabledImage = renderer.image { guard let cgImage = refuelImage.cgImage else { return } TemplateDrawingOperation(image: cgImage, imageSize: refuelImage.size, color: Asset.Station.refuelDisabledColor.color.cgColor) .apply(in: $0.cgContext) } return DefaultConfigurableStatefulButton.ViewModel(stateViewModelMap: [ .normal: .init(title: "Заправиться", image: refuelImage), .loading: .init(title: "Заправиться", image: nil), .disabled: .init(title: "Заправиться", image: disabledImage) ], currentState: .loading) } // Appearance static var refuelButtonAppearance: RefuelRow.Appearance { .make { $0.subviewAppearance { $0.set(appearanceBuilder: { $0.textAttributes = .smallActionButtonDefault $0.contentLayout.titleInsets = .horizontal(left: 8) $0.border.roundedCorners = .allCorners $0.border.cornerRadius = 4 }, for: [.normal, .loading, .disabled, .highlighted]) if let normalAppearance = $0.stateAppearances[.normal] { normalAppearance.backgroundColor = Asset.Common.mainRedColor.color.withAlphaComponent(0.1) } if let loadingAppearance = $0.stateAppearances[.loading] { loadingAppearance.backgroundColor = Asset.Common.disabledLightGrayColor.color.withAlphaComponent(0.3) loadingAppearance.textAttributes = .smallActionButtonDisabled } if let disabledAppearance = $0.stateAppearances[.disabled] { disabledAppearance.backgroundColor = Asset.Common.disabledLightGrayColor.color.withAlphaComponent(0.3) disabledAppearance.textAttributes = .smallActionButtonDisabled } if let highlightedAppearance = $0.stateAppearances[.highlighted] { highlightedAppearance.backgroundColor = Asset.Common.mainRedColor.color.withAlphaComponent(0.2) } $0.activityIndicatorPosition = .beforeTitle(padding: 8) $0.layout { $0.insets = .horizontal(16).vertical(top: 16, bottom: 0) $0.size = .fixedHeight(48) } } } } // Configuration static func buildRefuelRow(dataSource: FuelingStationInfoViewPresenter) -> RefuelRow { RefuelRow(item: dataSource.fuelingButtonViewModel) .with(appearance: Self.refuelButtonAppearance) } ``` # `DrawingOperation` - протокол для инкапсулирования низкоуровневых вызовов отрисовки CoreGraphicss. Позволяет: - сгруппировать низкоуровневые операции CoreGraphics в более высокоуровневые - использовать композицию из высокоуровневых операций - получить размер изображения необходимый для создания контекста ## Базовые операции "Из коробки" доступны самые часто испольуемые операции. ### `SolidFillDrawingOperation` Операция для заполнения области рисования выбранным цветом и выбранной формой. Например, можно нарисовать прямоугольник или круг на существующей области рисования или просто создать изображение с однотонный фоном. ```swift import TICoreGraphicsUtils import UIKit let solidFillSize = CGSize(width: 200, height: 200) let renderer = UIGraphicsImageRenderer(size: solidFillSize) let solidFillDrawingOperation = SolidFillDrawingOperation(color: UIColor.purple.cgColor, rect: CGRect(origin: .zero, size: solidFillSize)) let solidFillImage = renderer.image { solidFillDrawingOperation.apply(in: $0.cgContext) } ``` ### `BorderDrawingOperation` Операция создаёт рамку определённой формы и размера. ```swift let borderDrawingOperation = BorderDrawingOperation(frameableContentRect: CGRect(origin: .zero, size: solidFillSize), border: 2, color: UIColor.red.cgColor, radius: 4, exteriorBorder: false) let borderImage = renderer.image { borderDrawingOperation.apply(in: $0.cgContext) } ``` ### `CALayerDrawingOperation` Операция отрисовывает содержимое CALayer в изображение. Обычно используется для снапшота UIView. ```swift let button = UIButton(type: .custom) button.setTitle("This is button", for: .normal) button.setBackgroundImage(borderImage, for: .normal) button.sizeToFit() let layerDrawingOperation = CALayerDrawingOperation(layer: button.layer, offset: .zero) let layerRenderer = UIGraphicsImageRenderer(size: button.bounds.size) let buttonSnapshotImage = layerRenderer.image { layerDrawingOperation.apply(in: $0.cgContext) } ``` ### `TextDrawingOperation` Операция отрисовывает текст с заданными атрибутами ```swift let textDrawingOperaton = TextDrawingOperation(text: "This is string", textAttributes: [ .font: UIFont.boldSystemFont(ofSize: 15), .foregroundColor: UIColor.white ]) let textRenderer = UIGraphicsImageRenderer(size: textDrawingOperaton.desiredContextSize) let textImage = textRenderer.image { textDrawingOperaton.apply(in: $0.cgContext) } ``` ### `TemplateDrawingOperation` Операция заменяет все цвета кроме прозрачного на переданный цвет. Используется для приданиям иконкам определённого цвета (аналог tintColor). ```swift if let cgImage = textImage.cgImage { let templateDrawingOperation = TemplateDrawingOperation(image: cgImage, imageSize: textImage.size, color: UIColor.red.cgColor) let tintedImage = textRenderer.image { templateDrawingOperation.apply(in: $0.cgContext) } } ``` ### `TransformDrawingOperation` Операция производит изменение размера изображения учитывая пропорции и другие настройки (по аналогии с contentMode у UIImage). ```swift if let cgImage = textImage.cgImage { let newSize = CGSize(width: textImage.size.width / 1.5, height: textImage.size.height) let resizeOperation = TransformDrawingOperation(image: cgImage, imageSize: textImage.size, maxNewSize: newSize, resizeMode: .scaleAspectFill) let resizeRenderer = UIGraphicsImageRenderer(size: newSize) let resizedImage = resizeRenderer.image { resizeOperation.apply(in: $0.cgContext) } } ```
ivan.smolin added 5 commits 2023-07-10 17:48:15 +03:00
ivan.smolin added 1 commit 2023-07-10 18:38:50 +03:00
ivan.smolin added 1 commit 2023-07-11 16:30:39 +03:00
nikita.semenov reviewed 2023-07-14 12:02:29 +03:00
@ -53,3 +68,3 @@
}
public final class DefaultAppearance: BaseAppearance<UIView.DefaultWrappedLayout>, WrappedViewAppearance {
public final class DefaultStateAppearance: BaseAppearance<UIView.NoLayout, DefaultContentLayout>, ViewAppearance {
Member

Как будто бы по неймингу не понять, чем DefaultStateAppearance отличается от DefaultAppearance. Важно ли пользователю класса понимать различия в данном контексте?

Как будто бы по неймингу не понять, чем `DefaultStateAppearance` отличается от `DefaultAppearance`. Важно ли пользователю класса понимать различия в данном контексте?
Author
Member

State это настройки appearance для стейта, а второй для всего appearance кнопки.
Да, понимать разницу важно, если глубже dsl смотреть. А какие есть предложения по неймингу?

State это настройки appearance для стейта, а второй для всего appearance кнопки. Да, понимать разницу важно, если глубже dsl смотреть. А какие есть предложения по неймингу?
nikita.semenov reviewed 2023-07-14 12:03:39 +03:00
@ -39,0 +47,4 @@
currentImage
]
.compactMap { $0 }
.first { !($0.size.width.isZero || $0.size.height.isZero) } != nil
Member

тут отступы не слетели?

тут отступы не слетели?
Author
Member

стандартнык (кривые) Xcode'а :) могу поправить если что-то еще потребует правок

стандартнык (кривые) Xcode'а :) могу поправить если что-то еще потребует правок
nikita.semenov reviewed 2023-07-14 12:06:20 +03:00
@ -39,0 +49,4 @@
.compactMap { $0 }
.first { !($0.size.width.isZero || $0.size.height.isZero) } != nil
if hasNonEmptyImage {
Member

Не лучше будет делегировать весь этот if statement, либо в отдельный метод, либо в BaseContentLayout

Не лучше будет делегировать весь этот if statement, либо в отдельный метод, либо в `BaseContentLayout`
Author
Member

why not, ок сделаю

why not, ок сделаю
ivan.smolin added 1 commit 2023-07-17 18:51:47 +03:00
nikita.semenov approved these changes 2023-07-24 09:48:09 +03:00
ivan.smolin merged commit c2b31a90d6 into master 2023-07-24 10:00:38 +03:00
ivan.smolin deleted branch feature/stateful_button_improvements 2023-07-24 10:00:39 +03:00
Sign in to join this conversation.
No reviewers
No Label
No Milestone
No project
No Assignees
2 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: TouchInstinct/LeadKit#12
No description provided.