10 расширений Swift, которые мы используем на Livefront +3


AliExpress RU&CIS

Привет, Хабр. Перевод подготовлен в рамках онлайн-курса "iOS Developer. Basic".

Приглашаем всех желающих на бесплатный двухдневный интенсив «Создание простейшего приложения без единой строчки кода». В первый день узнаем:

1. Что такое XCode?
2. Как "рисуются экраны"
3. Добавим на экраны кнопки и поля ввода. Создадим экран авторизации.
4. Создадим второй экран нашего приложения и добавим переход на него из окна авторизации.
Зарегистрироваться можно здесь.


Добавьте в Swift свою собственную изюминку

Давайте будем честными. Фреймворки Swift и Apple не обладают всей функциональностью, необходимой при создании лучшего программного обеспечения для устройств Apple. К счастью, Swift поддерживает расширения, чтобы мы могли добавлять недостающие части, необходимые для более удобной работы.

Если вы новичок в Swift, пожалуйста, обратитесь к документации, чтобы узнать больше о Расширениях перед тем, как продолжить.

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

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

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

Вы можете найти Xcode Playground, используемый в этой статье, на моей странице GitHub.

Вот только 10 из многих расширений, которые мы используем в Livefront.

1. UIView — Ограничения

Добавление ограничений к UIView

import PlaygroundSupport
import UIKit

// Extension #1 - A helper method to add a view to another with top, left, bottom, and right constraints.
extension UIView {

    /// Add a subview, constrained to the specified top, left, bottom and right margins.
    ///
    /// - Parameters:
    ///   - view: The subview to add.
    ///   - top: Optional top margin constant.
    ///   - left: Optional left (leading) margin constant.
    ///   - bottom: Optional bottom margin constant.
    ///   - right: Optional right (trailing) margin constant.
    ///
    func addConstrained(subview: UIView,
                        top: CGFloat? = 0,
                        left: CGFloat? = 0,
                        bottom: CGFloat? = 0,
                        right: CGFloat? = 0) {
        subview.translatesAutoresizingMaskIntoConstraints = false
        addSubview(subview)

        if let top = top {
            subview.topAnchor.constraint(equalTo: topAnchor, constant: top).isActive = true
        }
        if let left = left {
            subview.leadingAnchor.constraint(equalTo: leadingAnchor, constant: left).isActive = true
        }
        if let bottom = bottom {
            subview.bottomAnchor.constraint(equalTo: bottomAnchor, constant: bottom).isActive = true
        }
        if let right = right {
            subview.trailingAnchor.constraint(equalTo: trailingAnchor, constant: right).isActive = true
        }
    }
}

// Implementation
class ViewController: UIViewController {

    let newView = UIView()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBlue

        newView.backgroundColor = .systemTeal
        view.addConstrained(subview: newView, top: 50, left: 100, right: -100)
    }
    
}

let viewController = ViewController()
PlaygroundPage.current.liveView = viewController

Вместо того, чтобы не забывать устанавливать translatesAutoresizingMaskIntoConstraints в false, добавлять отображение в родительское представление и устанавливать все индивидуальные ограничения, этот хелпер-метод выполнит все эти действия за вас. Этот метод позволяет установить верхние, передние, задние и нижние ограничения для родительского представления. Если вы опустите один из параметров ограничения, ограничение будет иметь нулевое значение, прикрепляя отображение к краю родительского представления. Чтобы полностью закрыть родительское представление, опустите все параметры ограничений.

2. Дата - Дата по всемирному координатному времени (UTC Date)

Создание объекта Date из строки в часовом поясе UTC

import Foundation

// Extension #2 - Create a date object from a date string with the UTC timezone.
//Inspired by: https://developer.apple.com/library/archive/qa/qa1480/_index.html
extension Date {
    /// Returns a date from the provided string.
    ///
    /// - Parameter utcString: The string used to create the date.
    ///
    /// - Returns: A date from the provided string.
    ///
    static func utcDate(from utcString: String) -> Date? {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.timeZone = TimeZone(abbreviation: "UTC")!
        return formatter.date(from: utcString)
    }
}

// Implementation
let utcDateString = "2021-04-03T14:00:00.000Z"
let utcDate = Date.utcDate(from: utcDateString) //Playgrounds will show this in the machine's timezone.
print(utcDate!)

API REST обычно возвращает строку даты в часовом поясе UTC. Вышеуказанный статический метод позволяет преобразовать строку в объект Date. Если у вас возникли проблемы с этим расширением в вашем собственном проекте, убедитесь, что dateFormat соответствует формату строки даты, которую вы получаете.

3. String (Строка) — получение URL-адресов

Получить действительные URL-адреса из строки

import Foundation

// Extension #3 - Retrieves valid URLs from a given string.
//Credit - Thanks to Paul Hudson for the core functionality on this extension.
//Source - https://www.hackingwithswift.com/example-code/strings/how-to-detect-a-url-in-a-string-using-nsdatadetector
extension String {
    /// Searches through a string to find valid URLs.
    /// - Returns: An array of found URLs.
    func getURLs() -> [URL] {
        var foundUrls = [URL]()
        guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
            return foundUrls
        }

        let matches = detector.matches(
            in: self,
            options: [],
            range: NSRange(location: 0, length: self.utf16.count)
        )

        for match in matches {
            guard let range = Range(match.range, in: self),
                  let retrievedURL = URL(string: String(self[range])) else { continue }
            foundUrls.append(retrievedURL)
        }

        return foundUrls
    }
}

// Implementation
let unfilteredString = "To get the best search results, go to https://www.google.com, www.duckduckgo.com, or www.bing.com"
let urls = unfilteredString.getURLs()

Этот хелпер-метод очень удобен, когда у вас есть несколько URL в заданной строке. Я бы настоятельно рекомендовал написать несколько юнит-тестов, чтобы убедиться, что этот метод извлекает предполагаемые URL-адреса для вашего конкретного JSON-ответа.

4. UIStackView — удаление представлений

Удаление всех subviews из UIStackView

import UIKit

// Extension #4 - Removes all views from a UIStackView.
extension UIStackView {
    /// Removes all arranged subviews and their constraints from the view.
    func removeAllArrangedSubviews() {
        arrangedSubviews.forEach {
            self.removeArrangedSubview($0)
            NSLayoutConstraint.deactivate($0.constraints)
            $0.removeFromSuperview()
        }
    }
}

// Implementation
let view1 = UIView()
let view2 = UIView()
let view3 = UIView()

let stackView = UIStackView()

//Add subviews to stackView
stackView.addArrangedSubview(view1)
stackView.addArrangedSubview(view2)
stackView.addArrangedSubview(view3)

//Confirm stackView contains 3 views
stackView.arrangedSubviews.count    //3
//Remove views from stackView
stackView.removeAllArrangedSubviews()

//Confirm stackView doesn't contain any subviews now
stackView.arrangedSubviews.count    //0

Есть несколько шагов, которые должны быть выполнены при удалении отображений из UIStackView, такие как удаление их из самого отображения стека, деактивация любых ограничений, и полное удаление из родительского отображения. Этот хелпер-метод позаботится обо всех этих шагах за вас.

5. Bundle — Версия приложения и номер сборки

Получить версию приложения и номер сборки

import Foundation

// Extension #5 - retrieve the app version # and build #.
//Inspired by https://stackoverflow.com/questions/25965239/how-do-i-get-the-app-version-and-build-number-using-swift
extension Bundle {
    /// Retrieve the app version # from Bundle
    var releaseVersionNumber: String? {
        return infoDictionary?["CFBundleShortVersionString"] as? String
    }

    /// Retrieve the build version # from Bundle
    var buildVersionNumber: String? {
        return infoDictionary?["CFBundleVersion"] as? String
    }
}

// Implementation
let releaseVersionNumber = Bundle.main.releaseVersionNumber
let buildVersionNumber = Bundle.main.buildVersionNumber

Это одна из тех особенностей, которые должны быть включены в Bundle. Вместо того, чтобы пытаться запомнить непонятный ключ словаря, эти вычисляемые свойства помогут в извлечении версии приложения и номера сборки. Многие приложения включают номер версии в меню настроек.

6. Календарь — предыдущий год

Определение прошлого года по типу Integer

import Foundation

// Extension #6 - Get the prior year as an integer
extension Calendar {
    /// Returns the prior year as an integer.
    ///
    /// - Returns: Returns last year's year as an integer.
    func priorYear() -> Int {
        guard let priorYear = date(byAdding: .year, value: -1, to: Date()) else {
            return component(.year, from: Date()) - 1
        }
        return component(.year, from: priorYear)
    }
}

//Implementation
let priorYearAsNumber = Calendar.current.priorYear()

Здесь все довольно прямолинейно. Метод вернет предыдущий год в виде Integer.

7. UIStackView — Удобство Init

Удобство Init (инициализация) чтобы упростить создание

import PlaygroundSupport
import UIKit

// Extension #7 - Make UIStackView creation a lot easier.
extension UIStackView {

    /// `UIStackView` convenience initializer for creating a stack view with arranged subviews, an
    /// axis and spacing.
    ///
    /// - Parameters:
    ///   - alignment: The alignment of the arranged subviews perpendicular to the stack view’s
    ///                axis.
    ///   - arrangedSubviews: The subviews to arrange in the `UIStackView`.
    ///   - axis: The axis that the subviews should be arranged around.
    ///   - distribution: The distribution of the arranged views along the stack view’s axis.
    ///   - spacing: The spacing to place between each arranged subview. Defaults to 0.
    ///
    convenience init(alignment: UIStackView.Alignment = .fill,
                     arrangedSubviews: [UIView],
                     axis: NSLayoutConstraint.Axis,
                     distribution: UIStackView.Distribution = .fill,
                     spacing: CGFloat = 0) {
        arrangedSubviews.forEach { $0.translatesAutoresizingMaskIntoConstraints = false }
        self.init(arrangedSubviews: arrangedSubviews)
        self.alignment = alignment
        self.axis = axis
        self.distribution = distribution
        self.spacing = spacing
    }
}

// Implementation
let view1 = UIView()
view1.backgroundColor = .systemPink
let view2 = UIView()
view2.backgroundColor = .systemOrange
let view3 = UIView()
view3.backgroundColor = .systemTeal

let stackView = UIStackView(alignment: .leading,
                            arrangedSubviews: [view1, view2, view3],
                            axis: .vertical,
                            distribution: .fill,
                            spacing: 20)



let view = UIView(frame: CGRect(x: 0, y: 0, width: 500, height: 500))
view.backgroundColor = .systemBlue
view.addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false

NSLayoutConstraint.activate([
    view1.heightAnchor.constraint(equalToConstant: 50),
    view1.widthAnchor.constraint(equalToConstant: 150),
    view2.heightAnchor.constraint(equalToConstant: 50),
    view2.widthAnchor.constraint(equalToConstant: 150),
    view3.heightAnchor.constraint(equalToConstant: 50),
    view3.widthAnchor.constraint(equalToConstant: 150),
    stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
    stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])

PlaygroundPage.current.liveView = view

Запомнить, какие свойства нужно установить для UIStackView, может быть непросто. Этот удобный инициализатор включает общие свойства в качестве своих параметров. Инициализатор также устанавливает translatesAutoresizingMaskIntoConstraints в false для каждого из представлений.

8. UIColor — Hex

Получение Hex (шестнадцатеричного) значения UIColor

import UIKit

// Extension #8 - generates a string with the hex color value.
//Inspired by: https://stackoverflow.com/a/26341062
extension UIColor {
    // MARK: - Helper Functions
    /// Returns the hex string for this `UIColor`. For example: `#FFFFFF` or `#222222AB` if the alpha value is included.
    ///
    /// - Parameter includeAlpha: A boolean indicating if the alpha value should be included in the returned hex string.
    ///
    /// - Returns: The hex string for this `UIColor`. For example: `#FFFFFF` or
    ///            `#222222AB` if the alpha value is included.
    ///
    func hexString(includeAlpha: Bool = false) -> String {
        let components = cgColor.components
        let red: CGFloat = components?[0] ?? 0.0
        let green: CGFloat = components?[1] ?? 0.0
        let blue: CGFloat = components?[2] ?? 0.0
        let alpha: CGFloat = components?[3] ?? 0.0
        let hexString = String.init(
            format: "#%02lX%02lX%02lX%02lX",
            lroundf(Float(red * 255)),
            lroundf(Float(green * 255)),
            lroundf(Float(blue * 255)),
            lroundf(Float(alpha * 255))
        )
        return includeAlpha ? hexString : String(hexString.dropLast(2))
    }
}

// Implementation
let whiteColor = UIColor(displayP3Red: 1, green: 1, blue: 1, alpha: 1)

let whiteHexString = whiteColor.hexString() //#FFFFFF
let blackColor = UIColor(displayP3Red: 0, green: 0, blue: 0, alpha: 1)

let blackHexString = blackColor.hexString() //#000000

Этот метод извлекает шестнадцатеричное значение UIColor и возвращает его в виде String. Это может быть очень полезно, если вы хотите сохранить и запомнить значение цвета для пользователя. Таким образом, вам нужно сохранить только шестнадцатеричную строку вместо трех целочисленных значений RGB.

9. UIViewController — Темный режим

Проверьте, включен ли темный режим

import UIKit

// Extension #9
extension UIViewController {
    /// Gets a flag indicating whether or not the UI is in dark mode.
    public var isDarkMode: Bool {
        if #available(iOS 12.0, *) {
            return traitCollection.userInterfaceStyle == .dark
        }
        return false
    }
}

UIColors, такие как .label, .systemBlue и т.д., автоматически настраиваются, когда пользователь переключается между светлым и темным режимом, но вы может быть захотите добавить дополнительные функции, когда пользователь переключает внешний вид устройства. Это вычисляемое свойство позволит вам проверить, какой внешний вид активен, чтобы вы могли соответствующим образом отреагировать.

10. UICollectionView — Последний IndexPath

Получить последний indexPath для collectionView

import PlaygroundSupport
import UIKit

// Extension #10 - get the last valid indexPath in a UICollectionView.
extension UICollectionView {

    /// Validates whether an `IndexPath` is a valid index path for an item in a collection view.
    ///
    /// - Parameter indexPath: The index path to validate.
    /// - Returns: `true` if the index path represents an item in the collection view or false
    ///     otherwise.
    ///
    func isValid(_ indexPath: IndexPath) -> Bool {
        guard indexPath.section < numberOfSections,
              indexPath.item < numberOfItems(inSection: indexPath.section)
        else {
            return false
        }

        return true
    }

    /// Provides the last valid `indexPath` in the collection view.
    /// - Parameter section: The section used to provide the last `indexPath`.
    /// - Returns: the last valid `indexPath` in the collection view or nil if not a valid `indexPath`.
    func lastIndexPath(in section: Int) -> IndexPath? {
        let lastIndexPath = IndexPath(row: numberOfItems(inSection: section) - 1, section: section)
        guard isValid(lastIndexPath) else { return nil }
        return lastIndexPath
    }
}

// Implementation
class CollectionViewController: UICollectionViewController {

    let items = Array(1...100)

    override func viewDidLoad() {
        super.viewDidLoad()

        collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
    }

    override func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }

    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return items.count
    }

    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
        cell.backgroundColor = .systemBlue
        return cell
    }
}

let collectionViewController = CollectionViewController(collectionViewLayout: UICollectionViewFlowLayout())
let lastIndexPath = collectionViewController.collectionView.lastIndexPath(in: 0)
lastIndexPath?.section  //0
lastIndexPath?.row      //99
PlaygroundPage.current.liveView = collectionViewController

Наконец, в UICollectionView добавлен метод, который возвращает последний допустимый indexPath. Это еще одна из тех функций, которая, кажется, уже должна существовать в UIKit. Хотя это может быть достигнуто путем подсчета количества элементов в collectionView и вычитания одного в контроллере представления; добавление его через расширение немного безопаснее.

Резюме

Я бы сказал, что практически невозможно создать проект без добавления хотя бы одного расширения. Добавление функциональности с помощью расширений делает Swift более мощным и позволяет создавать новые функции безопасным способом. Я советую вам поискать в Интернете "Расширения Swift" и получить удовлетворение от всех творческих решений, которые придумали наши коллеги-разработчики.

Не стесняйтесь поделиться своим любимым расширением (расширениями) в комментарии ниже.

Ресурсы:

Расширения протокола

Расширения Swift

Ограничения типов с помощью расширений


Узнать подробнее о курсе "iOS Developer. Basic"

Участвовать в интенсиве «Создание простейшего приложения без единой строчки кода»




Комментарии (9):

  1. storoj
    /#22998762

    1. "Ограничения"
      Я не сразу врубился о каких ограничениях идёт речь...

    API функции не очень понятен. Почему офсеты optional, но имеют дефолтное значение 0, а не nil? Предполагается, что по-умолчанию subview должна растягиваться на всю площадь родителя? На моей практике это очень редкий юзкейс, чтобы быть дефолтом.


    func test(value: CGFloat? = 0) {
        if let value = value {
            print("value is", value)
        }
    }
    
    test() // value is 0
    test(value: nil) // no output

    Для меня подобное поведение было бы неожиданным, я бы ожидал ровно обратного: по-умолчанию не происходит ничего, но если я задал офсет (в том числе нулевой) – то он применяется.


    Про саму целесообразность введения подобного расширения можно порассуждать отдельно.

    • storoj
      /#22999072

      теперь врубился, статья-перевод

  2. storoj
    /#22998826

    2: ISO8601DateFormatter.init()


    By default, a formatter is initialized to use the GMT time zone, the RFC 3339 
    standard format ("yyyy-MM-dd'T'HH:mm:ssZZZZZ"), and the following options: 
    withInternetDateTime, withDashSeparatorInDate, withColonSeparatorInTime, and withTimeZone.

    Если выкинуть доли секунды из этого формата, то можно пользоваться без дополнительной конфигурации. Если же очень сильно хочется знать время до микросекунды, то:


    let f = ISO8601DateFormatter()
    f.formatOptions = [.withInternetDateTime,
                       .withColonSeparatorInTime,
                       .withDashSeparatorInDate,
                       .withColonSeparatorInTimeZone,
                       .withFractionalSeconds]
    f.date(from: "2021-04-03T14:00:00.000Z") // "Apr 3, 2021 at 3:00 PM"

  3. storoj
    /#22998870

    3:


    func urls(in s: String) -> [URL] {
        // never ever fails, and if it does you are in a bigger trouble
        let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
        let matches = detector.matches(in: s, options: [], range: NSMakeRange(0, s.utf16.count))
        return matches.compactMap { $0.url }
    }
    
    urls(in: "hi https://habr.com, bye https://stackoverflow.com")
    // [https://habr.com, https://stackoverflow.com]

  4. storoj
    /#22998894

    4: UIStackView


    /* Removes a subview from the list of arranged subviews without removing it as
     a subview of the receiver.
        To remove the view as a subview, send it -removeFromSuperview as usual;
     the relevant UIStackView will remove it from its arrangedSubviews list
     automatically.
     */
    open func removeArrangedSubview(_ view: UIView)

    Судя по документации, достаточно просто: stackView.subviews.forEach { $0.removeFromSuperview() }.


    Более того, строка NSLayoutConstraint.deactivate($0.constraints) очень напрягает. Зачем это делать? Эта строка мало того, что не сделает ничего полезного в плане взаимоотношений со StackView, так ещё и уничтожит все остальные существующие constraints (относящиеся к её subviews например, или же constraints на ширину или высоту).

  5. storoj
    /#22998914

    5: "Должны быть", но всё равно Optional


    Это одна из тех особенностей, которые должны быть включены в Bundle

    Раз должны, то


    extension Bundle {
        var releaseVersionNumber: String {
            infoDictionary!["CFBundleShortVersionString"] as! String
        }
        var buildVersionNumber: String {
            infoDictionary!["CFBundleVersion"] as! String
        }
    }

  6. storoj
    /#22998958

    6: Криволинейная логика


    Здесь все довольно прямолинейно

    "Сначала пытаемся отнять год одним способом, но если не получилось, то и второй сойдёт."


    Почему же всегда не делать component(.year, from: Date()) - 1? Очень опасная функция, потому что она всегда возвращает предыдущий год на текущий момент времени. Мне было бы страшно вызывать её в каком-нибудь асинхронном коде 31 декабря в 23:59. Имхо предыдущий год можно брать только из какой-то конкретной даты, чтобы был как явный инпут, так и понятно откуда взявшийся результат.

  7. storoj
    /#22999002

    9: Узкое расширение


    Перед тем, как добавить какой-нибудь extension бывает полезно сделать cmd+click на свойстве, и понять, откуда оно взялось. В данном случае, можно было бы вместо UIViewController расширить UITraitEnvironment:


    extension UITraitEnvironment {
        var dark: Bool {
            traitCollection.userInterfaceStyle == .dark
        }
    }

    и тогда расширение станет доступным и в UIView тоже.

  8. Krypt
    /#22999974

    > Создание объекта Date из строки в часовом поясе UTC
    Вредный код. Форматтеры нужно кешировать. Их создание медленно. Настолько медленно, что вызывает лаг при прокрутке, если в ячейках таблицы содержится дата.

    Обычно я кладу кешированный форматтер в `NSThread.currentThread.threadDictionary`