iOS 8 — Widgets

  • Tutorial

С выходом iOS 8 у разработчиков появилась возможность создавать свои виджеты для экрана Today. Пока еще API окончательно не утряслось, есть Known Issue и много неописанных в документации моментов. Но если вы все же хотите сделать свой виджет, то прошу под кат (внимание, в примерах используется Swift).



Extensions


В iOS появилась новая концепция — расширения. Расширения позволяют сделать доступной какую-то часть контента и функциональности вне приложения.

Часть системы, которая поддерживает расширения, называется extension point. Для iOS доступны следующие extension point:
  • Today (Notification Center) — быстро выполнить какое-то действие или получить информацию через экран Today в Notification Center
  • Share — поделиться контентом с друзьями или в ленте на каком-нибудь сайте
  • Action — просматривать или управлять контентом внутри контекста другого приложения
  • Photo Editing — редактировать фото или видео внутри приложения Photos
  • Storage Provider — выбрать документ из набора документов доступных текущему приложению
  • Custom Keyboard — заменить родную клавиатуру iOS своей для использования во всех приложения

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

Следует особо отметить, что расширения являются особым видом бинарных файлов. Это не приложения!
К сожалению, расширения не поддерживают App to App IPC (пайпы, сокеты, ...), и поэтому нужно использовать всем знакомый [UIApplication openURL:] (сейчас он не работает для расширений, см. Known Issue) или, например, App Group.

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

Документация по расширениям лежит здесь.

Widgets


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

Виджет становится доступным после того, как пользователь установит приложение, содержащее виджет (сейчас бывает, что виджет не устанавливается после первого запуска приложения, все-таки это еще бета). Чтобы добавить виджет, надо открыть экран Today в Notification Center, нажать кнопку Edit и добавить нужный виджет.

Связь между контейнером и виджетом осуществляется через NotificationCenter.framework.

По сути, виджет — это UIViewController, который хорошо знаком любому iOS-программисту. Соответственно, при создании виджетов можно использовать накопленные ранее знания. Например, если надо выполнить какое-то действие перед отображением виджета, то следует переопределить viewWillAppear и т.д.

Чтобы виджет всегда выглядел актуальным, iOS иногда делает снапшоты виджета. Когда виджет снова становится видимым, сначала показывается последний снапшот, а лишь потом настоящее окно виджета. Чтобы виджет обновил свое состояние перед снапшотом, используется протокол NCWidgetProviding.
protocol NCWidgetProviding : NSObjectProtocol {
    
// Called to allow the client to update its state prior to a snapshot being taken, or possibly other operations.
// Clients should call the argument block when the work is complete, passing the appropriate 'NCUpdateResult'.
    @optional func widgetPerformUpdateWithCompletionHandler(completionHandler: ((NCUpdateResult) -> Void)!)
    
// Clients wishing to customize the default margin insets can return their preferred values.
// Clients that choose not to implement this method will receive the default margin insets.
    @optional func widgetMarginInsetsForProposedMarginInsets(defaultMarginInsets: UIEdgeInsets) -> UIEdgeInsets
}

Когда у виджета вызывается widgetPerformUpdateWithCompletionHandler, он должен обновить свое окно и после вызвать блок completionHandler с аргументом, равным одной из следующих констант:
  • NCUpdateResultNewData — новый контент требует обновления окна
  • NCUpdateResultNoData — виджету не нужно обновление
  • NCUpdateResultFailed — произошла ошибка в процессе обновления

Так как пользователи ждут мгновенной реакции от Notification Center, и система выполняет снапшоты, то виджет просто обязан хранить свое предыдущее состояние, т. е. кэшировать необходимые ему для работы данные.

Notification Center определяет ширину виджета, при этом виджет сам определяет свою высоту. Чтобы определить высоту, виджет может использовать Auto Layout или свойство preferedContentSize принадлежащее экзепляру UIViewController.
override func viewDidLoad() {
    super.viewDidLoad()     
    self.preferredContentSize.height = 350
 }

Получается, что к виджетам есть следующие требования:
  • гарантировать, что отображаемый контент актуален
  • должным образом реагировать на действия пользователя
  • потреблять минимально возможное количество ресурсов (iOS может убить виджет, если он будет потреблять много памяти)

Обратите внимание, что UI для виджетов имеет следующие ограничения:
  • нельзя показывать клавиатуру
  • нельзя использовать контролы, которые работают с жестами (например UIDatePicker)
  • пока что нельзя отобразить карты (сейчас этот пункт значиться в Known Issue — Mapviews do not load tiles in widgets.)

Так как клавиатура недоступна, пользователь должен иметь возможность конфигурировать виджет в приложении-контейнере.

Взаимодействие с виджетом


Для обновления информации, отображаемой виджетом, есть класс NCWidgetController.
Экземпляр этого класса имеет единственный метод setHasContent:forWidgetWithBundleIdentifier:, который посылает виджету сообщение о том, что он должен обновить информацию.
Используется следующим образом:
NCWidgetController.widgetController().setHasContent(true,
 forWidgetWithBundleIdentifier: "com.e-legion.Traffic.Widget")

Этот класс может использоваться из виджета и приложения-контейнера.

Обменным данными с приложением-контейнером


Для общения с контейнером используется объект NSExtensionContext доступный через свойство extensionContext, принадлежащее UIViewController.
class NSExtensionContext : NSObject {
    
    // The list of input NSExtensionItems associated with the context. If the context has no input items, this array will be empty.
    var inputItems: AnyObject[]! { get }
    
    // Signals the host to complete the app extension request with the supplied result items. The completion handler optionally contains any work which the extension may need to perform after the request has been completed, as a background-priority task. The `expired` parameter will be YES if the system decides to prematurely terminate a previous non-expiration invocation of the completionHandler. Note: calling this method will eventually dismiss the associated view controller.
    func completeRequestReturningItems(items: AnyObject[]!, completionHandler: ((Bool) -> Void)!)
    
    // Signals the host to cancel the app extension request, with the supplied error, which should be non-nil. The userInfo of the NSError will contain a key NSExtensionItemsAndErrorsKey which will have as its value a dictionary of NSExtensionItems and associated NSError instances.
    func cancelRequestWithError(error: NSError!)
    
    // Asks the host to open an URL on the extension's behalf
    func openURL(URL: NSURL!, completionHandler: ((Bool) -> Void)!)
}

Т.е чтобы открыть приложение-контейнер из виджета, контейнер должен зарегистрировать схему (например «traffic://»), и в код виджета надо добавить
self.extensionContext.openURL(NSURL(string: "traffic://"), completionHandler: nil)
.
По умолчанию, система безопасности iOS запрещает обмен данными между приложением-контейнером и расширением. Чтобы включить обмен данными, нужно добавить таргет контейнера и расширения в одну App Group.

В результате, файл entitlements будет иметь следующее содержимое:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.application-groups</key>
	<array>
		<string>group.96GT47C53G.traffic</string>
	</array>
</dict>
</plist>

Обратите внимание на 96GT47C53G. Это Development Team ID. Посмотреть его можно в своем профиле. Для запуска на симуляторе можно использовать любое значение, например, group.traffic,…

Теперь при помощи метода containerURLForSecurityApplicationGroupIdentifier можно получить путь до общей папки и хранить там общие для приложений данные.
NSFileManager.defaultManager().containerURLForSecurityApplicationGroupIdentifier("group.96GT47C53G.traffic")

Пример


Итак, попробуем создать виджет, который будет показывать карту с пробками. Информацию о пробках будем получать в виде картинки при помощи API Яндекс.Карт.

Пример ссылки для получения картинки: http://static-maps.yandex.ru/1.x/?ll=30.35,59.9690273&spn=0.01,0.2&size=300,250&l=map,trf
Собственно, lat и lon — это центр карты, а spn — это протяженность области показа карты в градусах.

Исходный код проекта доступен на GitHub.

Создаем приложение-контейнер


Задача контейнера — дать пользователю возможность сконфигурировать виджет, а именно выбрать область на карте. Таким образом, приложение будет содержать MapView для выбора области и кнопку «Set frame», которая передаст эту область виджету.


Приложение должно дать пользователю возможность выбрать параметры lat, lon, spn и передать их виджету. Этим занимается следующий код:
@IBAction func updateWidgetButtonTapped(sender : AnyObject) {
    var dict : NSMutableDictionary = NSMutableDictionary()
    dict["spn"] = self.mapView.region.span.latitudeDelta
    dict["lat"] = self.mapView.region.center.latitude
    dict["lon"] = self.mapView.region.center.longitude
      
    var dictUrl : NSURL = NSFileManager.defaultManager().containerURLForSecurityApplicationGroupIdentifier("group.96GT47C53G.traffic").URLByAppendingPathComponent("settings.dict")
    dict.writeToFile(dictUrl.path, atomically: true)
 
    NCWidgetController.widgetController().setHasContent(true, forWidgetWithBundleIdentifier: "com.e-legion.Traffic.Widget")
}

Добавляем виджет


Добавление виджета сводится к добавлению нового таргета.
Жмем File->New->Target и выбираем iOS->Application Extension->Today Extension.



Теперь в проект добавлена заглушка для виджета. Но если вы сейчас попробуете использовать ваш виджет, то у вас ничего не выйдет. Виджет будет крешиться. Чтобы это исправить, надо в TodayViewController добавить следующий метод:
init(coder aDecoder: NSCoder!) {
    super.init(coder: aDecoder)
}

Обратите внимание на файл Info.plist из шаблона. Он содержит ключ NSExtension, в котором определены некоторые параметры виджета.
<key>NSExtension</key>
	<dict>
		<key>NSExtensionMainStoryboard</key>
		<string>MainInterface</string>
		<key>NSExtensionPointIdentifier</key>
		<string>com.apple.widget-extension</string>
	</dict>

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

У каждого виджета есть небольшой сдвиг слева. Если вы хотите от него избавиться, то нужно возвращать нужный UIEdgeInsets в методе widgetMarginInsetsForProposedMarginInsets.
 func widgetMarginInsetsForProposedMarginInsets(defaultMarginInsets: UIEdgeInsets) -> UIEdgeInsets {
    return UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
 }

Код виджета достаточно прост. Есть метод updateMap, который выполняет обновление карты. Обновление происходит при начале отображения виджета (viewWillLoad), нажатии на кнопку и вызове widgetPerformUpdateWithCompletionHandler. Данные об отображаемой области виджет получает через containerURLForSecurityApplicationGroupIdentifier.

Заключение


Виджеты — очень классная штука, но пока все сыро. Иногда происходят креши, иногда виджет не виден и т.д. Но больше всего не хватает документации. Скоро все это будет исправлено и можно будет обогатить свои приложения новым функционалом, ну а пока можно поиграться с тем, что есть.
e-Legion
90.22
Лидер мобильной разработки в России
Share post

Similar posts

Comments 16

    +10
    Если у меня в Today больше пяти виджетов, сколько раз мне нужно будет сделать слайд по экрану что бы добраться до нужного?
    Виджеты — это хорошо, но в панели Today — сомнительное удобство.

    Видел в сети вот такой концепт, очень понравился:
      –3
      Вот интересно — некоторые фанатичные яблочники мне с пеной у рта доказывали, что отсутствие виджетов на iOS — это фича, этого никогда не будет и это никому не нужно. Что они скажут теперь?
        +4
        Это не совсем те виджеты, что в Андроиде или Dashboard МакОС. Информационная панель «Today» появилась еще с iOS7 куда были вынесены календарь, акции, напоминания. Сейчас по сути Apple открывает API этой панели для сторонних разработчиков.
          +18
          теперь они приползут к вам на коленях и начнут целовать пятки.
            0
            ну боже ж мой… я же специально выделил — фанатичные. Отличие фанатика от обычного пользователя — он не замечает недостатков своего и преимуществ чужого. По этой причине в диспутах их очень прикольно троллить — когда приводишь неоспоримые аргументы либо, как сейчас, когда они сами оказываются в плену своих слов.

            Кстати, себя я к фанатикам не отношу — юзаю Андроид исключительно по причине нищебродства, Apple мне за 5000 рублей ничего предложить не может, а вот китайцы — та да.
            +3
            Я могу ошибаться (не использую мобильные устройства от Apple), но по скриншотам и описаниям это очень похоже на Google Now.
            +2
            Вот бы еще туториал по созданию кастомной клавиатуры! :-)
              0
              >Custom Keyboard — заменить родную клавиатуру iOS своей для использования во всех приложения
              Боже! неужели появятся ssh клиенты с полноценной клавиатурой ака ctrl, alt, стрелочки и Fn?
                +2
                Вообще, конечно, и до этого не было преград для таких клиентов.
                  0
                  я говорю о полноценной клавиатуре, а не надстройками над стандартной
                  на экране в портретной ориентации стандартная клава занимает половину + полоска с доп клавишами + статус бар… не комфортно, сами понимаете
                    0
                    Возможно сделать свою клавиатуру любого размера, но только для своего приложения, а стандартную не показывать.
                      0
                      видимо разработчики ssh-клиентов повально об этом не подозревают…
                        0
                        Ну намного проще добавить пару дополнительных кнопок сверху стандартной. Я как-то писал кастомную клавиатуру и не сказал бы что это просто.
                          0
                          я уже выше описал, почему доп кнопки сверху — плохо
                0
                Почему код на свифте? Кому это будет полезно сейчас? Особенно учитывая что в продакшен свифт очень долго многие не потянут.
                  0
                  И даже более того, в Xcode 6.1 уже не собирается — ругается на синтаксис свифта через строку. Проще все на Obj-C переписать, чем править.

                Only users with full accounts can post comments. Log in, please.