Cook Book / 1.1. Safari Services



If you're interested in the living heart of what you do, focus on building things rather than talking about them.

Ryan Freitas, About.me

В этом уроке


Trivia

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


Playgrounds - живое представление и аниматор свойств

Живое представление или LiveView позволяет вынести какое-то представление или целый контроллер в специальное окно, что позволит им работать как приложению с одним окном (за исключением множества аспектов, вроде того, что это всё-таки не приложение - его нельзя запустить извне без дополнительных настроек, у него не всегда есть свой набор свойств).

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

Сперва создайте игровую площадку iOS и добавьте бесконечное выполнение для неё:

import UIKit
import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true

Теперь создадим представление в виде прямоугольника 125*125 и сделаем его живым:

let view = UIView(frame: CGRect(x: 0, y: 0, width: 125, height: 125))
PlaygroundPage.current.liveView = view

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

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

class Functions {
    @objc  func viewTapped(_ sender: Any) {
        let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) {
            //Анимации
        }
        animator.startAnimation()
    }
}

По аналогии с animate нам нужно добавить внутрь блока изменение анимируемых свойств. Попробуйте сделать таковыми задний фон, изменив его на белый, а также сжатие в два раза прямоугольника.

view.backgroundColor = #colorLiteral(red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0)
view.transform = CGAffineTransform(scaleX: 0.5, y: 0.5)

Сделаем распознаватель жестов:

let functions = Functions()
let gesture = UITapGestureRecognizer(target: functions, action: #selector(Functions.viewTapped(_:)))
view.addGestureRecognizer(gesture)

Чтобы открыть Live View, нужно выбрать соответствующий пункт в редакторе-ассистенте.

Теперь при нажатии на представление справа оно будет сжиматься.

Что будет, если сделать растяжение вместо сжатия? Ничего. Live View - небесконечная область, а ограничивается заданным ему представлением. А если нажать несколько раз? Будет всего одно сжатие, так как афинное преобразование задаёт изменение объекта относительно начального состояния и они не накапливаются.


Контроллер Safari

Предположим, Вам нужно встроить в приложение веб-браузер или внешнюю форму аутентификации с ресурсами компании. Что же делать? Есть два разных подхода:

В очень многих руководствах до сих пор описывается старая версия API. Отличить их можно по присутствию префикса WK - новое WKWebView, старое - WebView.

Создадим класс, который будет включать в себя контроллер Safari:

import SafariServices

let url = URL(string: "https://swiftworld.ru")!

class ViewController: UIViewController, SFSafariViewControllerDelegate {
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

    }
}

Теперь будем добавлять в метод появления главного представления контроллера функционал. Начнем с объекта конфигурации для представления:

let configuration: SFSafariViewController.Configuration = {
    let config = SFSafariViewController.Configuration()
    config.entersReaderIfAvailable = true
    //Если можно перейти в режим чтения, то мы это сделаем
    config.barCollapsingEnabled = true
    //Сжимается ли верхняя панель при прокрутке контента, как в Safari
    return config
}()

И сам контроллер:

let controller = SFSafariViewController(url: url, configuration: configuration)
do {
    controller.delegate = self
    controller.dismissButtonStyle = .close
    //Каков стиль конпки закрытия
    controller.preferredBarTintColor = #colorLiteral(red: 0.925490200519562, green: 0.235294118523598, blue: 0.10196078568697, alpha: 1.0)
    //Стиль панели навигации
}

present(controller, animated: true)

Необычно для нас здесь использование блока do без catch. Как альтернативу замыканиям (но лучше всё же использовать замыкания) возможно создавать участки кода с помощью do, при этом перехватывать что-либо необязательно. Такой блок ничем не отличается от обычного - он захватывает родительскую область видимости и создаёт свою локальную.

Также можно задать цвет элементов управления через свойство controller.preferredControlTintColor

И да - наша площадка уже позволяет нам просматривать сайты!

PlaygroundPage.current.liveView = ViewController()

Но нам этого мало: мы объявили, что наш класс реализует делегат для Safari. Добавим метод, который будет срабатывать по нажатию на эту кнопку:

func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
    dismiss(animated: true, completion: nil)
    UIApplication.shared.open(url)
}

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

На iOS в приложении Swift Playgrounds запрещено явное использование доступа к UIApplication.shared.

Другие методы делегата:




SFAuthenticationSession

До 11-ой версии, Фреймворк позволял разделять контент обычного Safari и всех объектов данного типа. Но затем в целях безопасности это было отключено.

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

let session = SFAuthenticationSession(url: url, callbackURLScheme: nil) { (url, error) in
    print(error, url)
}
session.start()

Чтобы эти данные были доступны, приложение должно всегда где-то хранить сильную ссылку на объект типа. Аргументы:

  1. Ссылка, на которую должен быть выполнен переход
  2. Коллбэк-схема, на которую будут переправлены данные для аутентификации. Очень полезно в системах аутентификации наподобие OAuth.
  3. Замыкание, что будет выполнено по завершению действий. Если пользователь откажется предоставлять доступ, то гарантированно произойдёт ошибка.

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


Анкоры для читабельного контента

С точки зрения логического устройства глаза - человеческого зрение старается найти какие-то области резкости на изображениях - переходы, символы, выделения. Но если размещать текст близ границы какой-то области, то его станет менее удобно читать. Все представления обладают специальным свойством readableContentGuide, через которое возможно задать область, в которой можно помещать объекты, которые должно быть можно отделять зрением.

Сперва создадим обычный лейбл:

let label: UILabel = {
    let label = UILabel()
    label.translatesAutoresizingMaskIntoConstraints = false
    label.backgroundColor = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1)
    label.text = "Hello, world"
    label.sizeToFit()
    //Расширяем представление, чтобы уместить его контент.
    return label
}()
view.addSubview(label)
PlaygroundPage.current.liveView = view

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

Вы можете отрендерить Вашу разметку игровой площадки, перейдя в Editor -> Show Rendered Markup

Чтобы исправить это создадим ограничения, которые прикрепят наше представление к зоне читаемости:

label.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor).isActive = true
label.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor).isActive = true
label.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor).isActive = true
label.topAnchor.constraint(equalTo: view.readableContentGuide.topAnchor).isActive = true

Метку стало легче отследить взглядом!


Сенсор касаний и экран

Есть ряд устройств, на которых сенсор касания срабатывает быстрее, чем экран - так на iPad Air 2 частота обновления экрана 60 герц (она же задаёт верхний предел частоты кадров), а сенсор способен получать касания в два раза чаще. В любом случае система не сможет принять больше действий в секунду, чем позволяет частота экрана. Потому часть действий пересекается, если они расположены близко друг к другу.

Мы попробуем создать систему, ускоряющую касания, путём использования всех пересекающихся действий, а не только основного.

class MyView: UIView {
var points = [CGPoint]()

    func drawForFirstTouch(in set: Set<UITouch>, event: UIEvent?) {

        guard let touch = set.first,
            let event = event,
            let touches = event.coalescedTouches(for: touch),
            //Пересекающиеся действия для события
            touches.count > 0 else {
                return
        }

        points += touches.map{$0.location(in: self)}

        setNeedsDisplay()
    }
}

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

Запрос на перерисовку отработает только для объектов, с нативными слоями - например, из Core Graphics, UIKit и так далее. К счастью почти все и всё используют именно их.

Можно перезаписать метод, срабатывающий при начале касаний к представлению:

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    points.removeAll(keepingCapacity: true)
    drawForFirstTouch(in: touches, event: event)
}

Мы очищаем уже имеющиеся точки и заносим точки первого касания.

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

override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
    points.removeAll(keepingCapacity: true)
    setNeedsDisplay(bounds)
}

В таком случае мы сбрасываем точки и перерисовываем представление.

При смещении пальца пользователя происходит move:

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
    drawForFirstTouch(in: touches, event: event)
}

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

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    guard let touch = touches.first, let event = event, let predictedTouches = event.predictedTouches(for: touch), predictedTouches.count > 0 else {
        return
    }

    points += predictedTouches.map{$0.location(in: self)}
    setNeedsDisplay()
}

Мы используем их для более плавного окончания рисования.

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

Напоследок перезапищем метод для отрисовки представления:

override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()
    //Для примитивного рисования нужно получить текущий графический контекст
    context?.setFillColor(#colorLiteral(red: 0.1215686275, green: 0.1294117647, blue: 0.1411764706, alpha: 1))
    context?.fill(rect)
    //Мы заполняем весь фон черным цветом.

    context?.setStrokeColor(#colorLiteral(red: 0.9215686275, green: 0.3764705882, blue: 0.2745098039, alpha: 1))
    //Задаем цвет для линий

    for point in points {
        context?.move(to: point)
        //Обходим от точки к точке

        //И если она не последняя, то соединяем их линиями
        if let last = points.last, point != last {
            let next = points[points.index(of: point)! + 1]
            context?.addLine(to: next)
        }
    }

    context?.strokePath()
}

Есть множество методов рисования в Core Graphics по контексту, но их слишком много, чтобы расписывать их здесь все. Вы можете подробнее узнать о них здесь CGLayer - Core Graphics | Apple Developer Documentation

И последний штрих:

let myView = MyView(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
myView.backgroundColor = #colorLiteral(red: 0.9215686275, green: 0.3764705882, blue: 0.2745098039, alpha: 1)
PlaygroundPage.current.liveView = myView

Лучше запускать это на реальном устройстве, так как игровая площадка в Xcode для кода на iOS использует неявный симулятор.


Команды нажатия на клавиши

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

class KeyViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        let command = UIKeyCommand(input: "N", modifierFlags: [.command, .alternate, .control], action: #selector(handleCommand(_:)))
        addKeyCommand(command)
    }
}

PlaygroundPage.current.liveView = KeyViewController()

Так в строке надо передать саму клавишу, а в флаги модификаторы - специальные клавиши, которые должны быть нажаты с данной. Мы сделали команду по нажатию CMD+CTRL+OPT+N.

Долгое нажатие CMD вызовет меню со всеми доступными в данный момент клавиатурными сочетаниями.

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

@objc  func handleCommand(_ cmd: UIKeyCommand) {
    let alert = UIAlertController(title: "Shortcut Pressed", message: "You pressed the shortcut key", preferredStyle: .alert)
    alert.addAction(UIAlertAction(title: "Ok!", style: .destructive, handler: nil))
    present(alert, animated: true, completion: nil)
}