SA1040 — Рефакторинг (Refactoring)
Содержание страницы
В StoreSearch все выглядит хорошо, но в приложении все еще есть несколько шероховатостей.
Если вы начнете поиск и переключитесь в альбомную ориентацию во время загрузки результатов, альбомная ориентация останется пустой. Вы можете воспроизвести эту ситуацию, искусственно замедлив сетевое соединение с помощью инструмента Network Link Conditioner.
Также было бы неплохо показать счетчик активности на ландшафтном экране во время поиска.
Вы отполируете некоторые из этих грубых краев в этой главе и рассмотрим следующее:
- Рефакторинг поиска: рефакторинг кода для помещения логики поиска в свой собственный класс, чтобы иметь централизованный доступ к состоянию поиска и его результатам.
- Улучшение категорий: Создайте перечисление категорий, чтобы определить категории iTunes типобезопасным образом.
- Перечисления со связанными значениями: Используйте перечисления со связанными значениями для поддержания состояния поиска и результатов поиска.
- Spin me right round: Добавьте индикатор активности в ландшафтный вид. Также добавьте в приложение индикатор сетевой активности.
- Ничего не найдено: Обновите ландшафтный вид, чтобы отобразить сообщение при отсутствии доступных результатов поиска.
- Всплывающее окно сведений: отображение всплывающего окна сведений при нажатии на любой результат поиска в ландшафтном режиме.
Рефакторинг поиска
Так как же LandscapeViewController
определить, в каком состоянии находится поиск? Его searchResults
массив будет пуст, если поиск не был выполнен или поиск еще не завершен. Кроме того, он может иметь нулевые SearchResult
объекты даже после успешного поиска. Таким образом, вы не можете определить, продолжается ли поиск или он завершен, просто взглянув на объект массива. Вполне возможно, что searchResults
в любом случае массив будет иметь счетчик 0.
Вам нужен способ определить, продолжается ли поиск. Возможное решение-SearchViewController
передать isLoading
флаг LandscapeViewController
, но мне это кажется неправильным. Это известно как запах кода, намек на более глубокую проблему с дизайном программы.
Вместо этого давайте возьмем логику поиска SearchViewController
и поместим ее в собственный класс Search
. Затем вы можете получить все состояния, относящиеся к активному поиску, от этого Search
объекта. Время для некоторого рефакторинга!
Класс поиска
BOS Если вы хотите, создайте новую ветвь для этого в Git.
Это довольно полное изменение кода, и всегда есть риск, что оно сработает не так, как вы надеялись. Внося изменения в новую ветвь, вы можете зафиксировать свои изменения, не испортив основную ветвь. Кроме того, вы можете вернуться к основной ветке, если изменения не сработают. Создание новых ветвей в Git происходит быстро и легко, так что хорошо бы войти в привычку.
BOS Создайте новый файл с помощью шаблона Swift File. Назовите это Поиском.
BOS Измените содержимое Search.swift на:
`import Foundation
class Search {
var searchResults: [SearchResult] = []
var hasSearched = false
var isLoading = false
private var dataTask: URLSessionDataTask?
func performSearch(for text: String, category: Int) {
print(«Searching…»)
}
}`
Вы дали этому классу три внутренних свойства, одно частное свойство и метод. Эта штука должна выглядеть знакомой, потому что она идет прямо отсюда SearchViewController
.
Вы будете удалять код из этого класса и помещать его в этот новый Search
класс.
Этот performSearch(for:category:)
метод пока мало что дает, но это нормально. Сначала я хочу, чтобы вы SearchViewController
поработали с этим новым Search
объектом, и когда он скомпилируется без ошибок, вы перенесете всю логику на него. Детские шаги!
Переместить код
Давайте внесем изменения в SearchViewController.swift. Xcode, вероятно, выдаст кучу ошибок и предупреждений, пока вы вносите эти изменения, но в конце концов все получится.
BOS В SearchViewController.swiftудалите объявления для следующих свойств:
var searchResults: [SearchResult] = [] var hasSearched = false var isLoading = false var dataTask: URLSessionDataTask?
И замените их вот этим:
private let search = Search()
Новый Search
объект не только описывает состояние и результаты поиска, но и инкапсулирует всю логику взаимодействия с веб-службой iTunes. Теперь вы можете удалить много кода из контроллера представления.
BOS Переместите следующие методы в Search.swift:
iTunesURL(searchText:category:)
parse(data:)
Сделайте эти методы .private
Они важны только для Search
него самого, а не для каких-либо других классов приложения, поэтому их хорошо “спрятать”.
BOS Вернитесь в SearchViewController.swift, замените performSearch()
метод следующим (совет: отложите старый код во временный файл, потому что он вам понадобится позже).
`func performSearch() {
search.performSearch(
for: searchBar.text!,
category: segmentedControl.selectedSegmentIndex)
tableView.reloadData()
searchBar.resignFirstResponder()
}`
Это просто заставляет Search
объект выполнять всю работу. Конечно, вы все равно перезагружаете табличное представление — чтобы показать счетчик активности — и скрываете клавиатуру.
В коде есть несколько мест, которые все еще используют старый searchResults
массив, даже если он больше не существует. Вы должны изменить их, чтобы использовать searchResults
свойство из Search
объекта. Аналогично hasSearched
и для isLoading
И.
Например, измените наtableView(_:numberOfRowsInSection:)
:
func tableView( _ tableView: UITableView, numberOfRowsInSection section: Int ) -> Int { if search.isLoading { return 1 // Loading... } else if !search.hasSearched { return 0 // Not searched yet } else if search.searchResults.count == 0 { return 1 // Nothing Found } else { return search.searchResults.count } }
Аналогично вышесказанному, найдите другие места в коде, куда переместились соответствующие свойства, и внесите необходимые изменения. Если вы не уверены, куда вносить изменения, поищите ошибки Xcode — на этом шаге, как только вы внесете все изменения правильно, код снова скомпилируется без каких-либо ошибок.
BOS В showLandscape(with:)
поле измените строку , задающую searchResults
свойство на новом контроллере представления, с:
controller.searchResults = search.searchResults
Для:
controller.search = search
Эта строка выдаст ошибку после внесения изменений, но в следующий раз вы ее исправите.
У LandscapeViewController
него все еще есть свойство для searchResults
массива, поэтому вы должны изменить его, чтобы использовать Search
объект.
В LandscapeViewController.swiftудалите searchResults
переменную экземпляра и замените ее:
var search: Search!
BOS In viewWillLayoutSubviews()
, измените вызов на tileButtons()
into:
tileButtons(search.searchResults)
Итак, это первый раунд изменений. Создайте приложение, чтобы убедиться в отсутствии ошибок компилятора.
Добавьте логику поиска обратно в
Само приложение больше ничего не делает, потому что вы удалили всю логику поиска. Так что давайте вставим это обратно.
BOS В Search.swiftperformSearch(for:category:)
замените его следующим (вы можете использовать этот временный файл из предыдущих версий, но будьте осторожны, чтобы внести соответствующие изменения).:
`func performSearch(for text: String, category: Int) {
if !text.isEmpty {
dataTask?.cancel()
isLoading = true
hasSearched = true
searchResults = []
let url = iTunesURL(searchText: text, category: category)
let session = URLSession.shared
dataTask = session.dataTask(with: url) {
data, response, error in
// Was the search cancelled?
if let error = error as NSError?, error.code == -999 {
return
}
if let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200, let data = data {
self.searchResults = self.parse(data: data)
self.searchResults.sort(by: <)
print("Success!")
self.isLoading = false
return
}
print("Failure! \(response!)")
self.hasSearched = false
self.isLoading = false
}
dataTask?.resume()
}
}`
Это в основном та же логика, что и раньше, за исключением того, что весь код пользовательского интерфейса был удален. Цель Search
состоит в том, чтобы просто выполнить поиск, он не должен делать никаких вещей пользовательского интерфейса. Это работа контроллера представления.
BOS Запустите приложение и найдите что-нибудь. Когда поиск заканчивается, Консоль показывает сообщение “Успешно!”, Но табличное представление не перезагружается, и спиннер продолжает вращаться целую вечность.
В Search
настоящее время объект не может сказатьSearchViewController
, что это сделано. Вы можете решить эту проблему, сделав SearchViewController
делегат Search
объекта, но для подобных ситуаций замыкания гораздо удобнее.
Закрытие SearchComplete
Давайте создадим ваше собственное закрытие!
BOS Добавьте следующую строку в Search.swift, над class
строкой:
typealias SearchComplete = (Bool) -> Void
typealias
Объявление позволяет создать более удобное имя для типа данных, чтобы сохранить некоторые нажатия клавиш и сделать код более читабельным.
Здесь вы объявляете тип для своего собственного замыкания с именем SearchComplete
. Это замыкание, которое не возвращает никакого значения (оно есть Void
) и принимает один параметр-a Bool
. Если вы считаете этот синтаксис странным, то я с вами согласен, но так оно и есть.
Отныне вы можете использовать это имя SearchComplete
для ссылки на замыкание, которое принимает Bool
параметр и не возвращает значения.
Типы замыканий
Всякий раз, когда вы видите a ->
в определении типа, этот тип предназначен для замыкания, функции или метода.
Свифт рассматривает эти три вещи как в основном взаимозаменяемые. Замыкания, функции и методы-это все блоки исходного кода, которые могут принимать параметры и возвращать значение. Разница в том, что функция-это просто замыкание с именем, а метод-это функция, которая живет внутри объекта.
Некоторые примеры типов замыканий:
() -> ()
это замыкание, которое не принимает параметров и не возвращает значения.
Void -> Void
это то же самое, что и в предыдущем примере – Void
и ()
означает то же самое.
(Int) -> Bool
это замыкание, которое принимает один параметр an Int
и возвращает a Bool
.
Int -> Bool
это то же самое, что и выше. Если есть только один параметр, вы можете опустить круглые скобки.
(Int, String) -> Bool
это замыкание, принимающее два параметра an Int
и a String
и возвращающее a Bool
.
(Int, String) -> Bool?
как и выше, но теперь возвращает необязательное Bool
значение.
(Int) -> (Int) -> Int
это замыкание, которое возвращает другое замыкание, которое возвращает an Int
. Чумовой! Swift обрабатывает замыкания как любой другой тип объекта, поэтому вы также можете передавать их в качестве параметров и возвращать из функций.
BOS Внесите следующие изменения вperformSearch(for:category:)
:
`func performSearch(
for text: String,
category: Int,
completion: @escaping SearchComplete) { // new
if !text.isEmpty {
. . .
dataTask = session.dataTask(with: url) {
data, response, error in
var success = false // new
. . .
if let httpResponse = response as? . . . {
. . .
self.isLoading = false
success = true // instead of return
}
if !success { // new
self.hasSearched = false
self.isLoading = false
} // new
// New code block - add the next three lines
DispatchQueue.main.async {
completion(success)
}
}
dataTask?.resume()
}
}`
Вы добавили третий параметр с именем completion
типа SearchComplete
. Тот, кто звонитperformSearch(for:category:completion:)
, теперь может предоставить свое собственное закрытие, и метод выполнит код, который находится внутри этого закрытия, когда поиск завершится.
Примечание:
@escaping
Аннотация необходима для замыканий, которые не используются сразу. Это говорит Swift, что этому закрытию может потребоваться захватить такие переменные, какself
и удерживать их некоторое время, пока закрытие не будет выполнено, в данном случае, когда поиск будет завершен.
Вместо того чтобы рано возвращаться из закрытия после успеха, теперь вы устанавливаете success
переменную на true
замену return
оператора. Значение success
используется для Bool
параметра completion
закрытия, как вы можете видеть внутри вызова to DispatchQueue.main.async
внизу.
Чтобы выполнить код из замыкания, вы просто вызываете его так же, как вызываете любую функцию или метод: closureName(parameters)
. Вы completion(true)
призываете успех и completion(false)
неудачу. Это делается для того, чтобы SearchViewController
можно было перезагрузить свое табличное представление или, в случае ошибки, показать предупреждающее представление.
BOS В SearchViewController.swiftзаменить performSearch()
на:
`func performSearch() {
search.performSearch(
for: searchBar.text!,
category: segmentedControl.selectedSegmentIndex) { success in
if !success {
self.showNetworkError()
}
self.tableView.reloadData()
}
tableView.reloadData()
searchBar.resignFirstResponder()
}`
Теперь вы передаете закрытие – как конечное закрытие – performSearch(for:category:completion:)
к. Код в этом закрытии вызывается после завершения поиска, причем success
параметром является либо true
или false
. Намного проще, чем сделать делегата, не так ли? Закрытие всегда вызывается в основном потоке, поэтому здесь безопасно использовать код пользовательского интерфейса.
BOS Запустите приложение. Вы должны быть в состоянии искать снова.
Это первая часть завершенного рефакторинга. Вы извлекли соответствующий код для поиска из SearchViewController
него и поместили его в свой собственный объект Search
. Контроллер представления теперь делает только вещи, связанные с представлением, и именно так он и должен работать.
BOS Вы внесли довольно много обширных изменений, так что это хорошая идея, чтобы зафиксировать их.
Улучшение категорий
Идея строгой типизации Swift заключается в том, что тип данных переменной должен быть как можно более описательным. Прямо сейчас категория для поиска представлена числом от 0 до 3, но разве это лучший способ описать категорию для вашей программы?
Если вы видите цифру 3, означает ли это для вас “электронная книга”? Это может быть что угодно… А что, если вы используете 4, 99 или -1, что бы это значило? Все это допустимые значения для anInt
, но не для категории. Единственная причина, по которой категория в настоящее время является anInt
, заключается в том, что segmentedControl.selectedSegmentIndex
это an Int
.
Представить категорию в виде перечисления
Есть только четыре возможные категории поиска, так что это звучит как работа для перечисления!
BOS Добавьте в Search.swift следующее, заключенное в class
скобки::
enum Category: Int { case all = 0 case music = 1 case software = 2 case ebooks = 3 }
Это создает новый тип перечисления Category
с четырьмя возможными значениями. Каждый из них имеет связанное с ним числовое значение, называемое необработанным значением.
Сравните это с AnimationStyle
перечислением, которое вы сделали раньше:
enum AnimationStyle { case slide case fade }
Это перечисление не связывает числа со своими значениями — оно не говорит : Int
за именем перечисления. Ибо AnimationStyle
не имеет значения, что slide
на самом деле является числом 0 и fade
числом 1, или какими бы ни были эти значения. Все , что вас волнует, это то, что переменная типа AnimationStyle
может быть либо .slide
или.fade
, числовое значение не важно.
Однако для Category
перечисления вы хотите связать его четыре значения с четырьмя возможными индексами сегментированного элемента управления. Если выбран сегмент 3, вы хотите, чтобы это соответствовало .ebooks
. Вот почему элементы из Category
перечисления имеют связанные номера.
Используйте перечисление категорий
BOS Измените сигнатуру методаperformSearch(for:category:completion:)
, чтобы использовать этот новый тип:
func performSearch( for text: String, category: Category, completion: @escaping SearchComplete) {
Этот category
параметр больше не является an Int
. Передать ему значение 4, 99 или -1 больше невозможно. Это всегда должно быть одно из значений из Category
перечисления. Это уменьшает потенциальный источник ошибок и делает программу более выразительной. Всякий раз, когда у вас есть ограниченный список возможных значений, которые можно превратить в перечисление, это стоит сделать!
BOS Также изменитьiTunesURL(searchText:category:)
, потому что это также предполагалось category
быInt
:
`private func iTunesURL(searchText: String, category: Category) -> URL {
let kind: String
switch category {
case .all: kind = «»
case .music: kind = «musicTrack»
case .software: kind = «software»
case .ebooks: kind = «ebook»
}
let encodedText = . . .`
switch
Теперь рассмотрим различные случаи из Category
перечисления вместо чисел от 0 до 3. Обратите внимание, что default
регистр больше не нужен, поскольку category
параметр не может иметь никаких других значений.
Этот код работает, но, честно говоря, я не совсем доволен им. Я уже говорил, что любая логика, связанная с объектом, должна быть неотъемлемой частью этого объекта. Другими словами, объект должен делать столько, сколько он может сам.
Хорошим примером является преобразование категории в строку “вид”, которая входит в URL-адрес iTunes. Это похоже на тоCategory
, что может сделать само перечисление.
Перечисления Swift могут иметь свои собственные методы и свойства. Итак, давайте воспользуемся этим преимуществом и еще больше улучшим код.
BOS Добавьте type
свойство в Category
перечисление.:
`enum Category: Int {
case all = 0
case music = 1
case software = 2
case ebooks = 3
var type: String {
switch self {
case .all: return «»
case .music: return «musicTrack»
case .software: return «software»
case .ebooks: return «ebook»
}
}
}`
Перечисления Swift не могут иметь переменных экземпляра, только вычисляемые свойства. type
имеет точно такой же switch
оператор, который вы только что видели , за исключением того, что он включает self
текущее значение объекта перечисления.
iTunesURL(searchText:category:)
Теперь вы можете просто написать:
private func iTunesURL(searchText: String, category: Category) -> URL { let kind = category.type let encodedText = . . .
Это намного чище. Все, что имеет отношение к категориям, теперь живет внутри своего собственного перечисления Category
.
Преобразование Int в категорию
Вам все равно нужно рассказать SearchViewController
об этом, потому что для этого нужно преобразовать выбранный индекс сегмента в правильное Category
значение.
BOS В SearchViewController.swiftизмените первую часть performSearch()
на:
func performSearch() { if let category = Search.Category( rawValue: segmentedControl.selectedSegmentIndex) { // New line search.performSearch( for: searchBar.text!, category: category) { success in // Change to category . . . } . . . } // New line }
Чтобы преобразовать Int
значение из selectedSegmentIndex
в элемент из Category
перечисления, используется встроенный init(rawValue:)
метод. Это может привести к сбою — например, когда вы передаете число, которое не покрывается одним из Category
случаев, то есть все, что находится за пределами диапазона от 0 до 3. Вот почему init(rawValue:)
возвращает необязательный параметр, который необходимо развернуть, if let
прежде чем вы сможете его использовать.
Примечание: Поскольку вы поместили
Category
перечисление внутриSearch
класса, его полное имя таковоSearch.Category
. Другими словами,Category
живет внутриSearch
пространстваимен. Имеет смысл объединить эти две вещи, потому что они так тесно связаны.
BOS Создайте и запустите, чтобы увидеть, работают ли различные категории по-прежнему.
Перечисления со связанными значениями
Перечисления довольно полезны для ограничения чего-то ограниченным диапазоном возможностей, как то, что вы сделали с категориями поиска. Но они даже более могущественны, чем вы могли бы ожидать, как вы скоро узнаете…
Как и все объекты, Search
объект имеет определенное количество состояний. Ибо Search
это определяется его isLoading``hasSearched
переменными,searchResults
, и.
Эти три переменные описывают четыре возможных состояния:

Search
Объект находится только в одном из этих состояний одновременно, и когда он переходит из одного состояния в другое, происходит соответствующее изменение в пользовательском интерфейсе приложения. Например, при переходе от “поиска” к “иметь результаты” приложение скрывает счетчик действий и загружает результаты в табличное представление.
Проблема в том, что это состояние разбросано по трем разным переменным. Сложно понять, каково текущее состояние, просто взглянув на эти переменные.
Консолидация состояния поиска
Вы можете улучшить ситуацию, указав Search
явную state
переменную. Самое классное, что это избавляет от isLoading
, hasSearched
, и даже searchResults
от переменных массива. Теперь есть только одно место, на которое вы должны посмотреть, чтобы определить, что Search
происходит в данный момент.
BOS В Search.swiftудалите следующие переменные экземпляра:
var searchResults: [SearchResult] = [] var hasSearched = false var isLoading = false
BOS На их месте добавьте следующее перечисление, которое снова входит в класс:
enum State { case notSearchedYet case loading case noResults case results([SearchResult]) }
Это перечисление имеет свой случай для каждого из четырех перечисленных выше государств. Ему не нужны необработанные значения, поэтому случаи не имеют чисел — обратите внимание, что состояние .notSearchedYet
также используется при возникновении ошибки.
.results
Случай особый: с ним связано значение — массив SearchResult
объектов.
Этот массив важен только тогда, когда поиск успешен. Во всех остальных случаях результатов поиска нет, а массив пуст — см. Таблицу состояний выше. Сделав его связанным значением, вы будете иметь доступ к этому массиву только тогда, когда Search
он находится в .results
состоянии. В других состояниях массив просто не существует.
Используйте новое перечисление состояний
Давайте посмотрим, как это работает.
BOS Сначала добавьте новую переменную экземпляра.:
private(set) var state: State = .notSearchedYet
Это отслеживает Search
его текущее состояние. Его начальное значение .notSearchedYet
— очевидно, что при первом Search
построении объекта поиск еще не проводился.
Эта переменная есть private
, но только наполовину. Для других объектов вполне разумно спроситьSearch
, каково их текущее состояние. На самом деле приложение не будет работать, если вы этого не разрешите.
Но вы не хотите, чтобы эти другие объекты могли изменять значение state
; им разрешено только считывать значение состояния. Если private(set)
вы скажете Swift, что чтение нормально для других объектов, но присвоение (или установка) новых значений этой переменной может происходить только внутри Search
класса.
ИзменитьperformSearch(for:category:completion:)
, чтобы использовать эту новую переменную.:
`func performSearch(
for text: String,
category: Category,
completion: @escaping SearchComplete
) {
if !text.isEmpty {
dataTask?.cancel()
// Remove the next 3 lines and replace with the following
state = .loading
. . .
dataTask = session.dataTask(with: url) {
data, response, error in
var newState = State.notSearchedYet // add this
. . .
if let httpResponse = response . . . {
// Replace all code within this if block with following
var searchResults = self.parse(data: data)
if searchResults.isEmpty {
newState = .noResults
} else {
searchResults.sort(by: <)
newState = .results(searchResults)
}
success = true
}
// Remove "if !success" block
DispatchQueue.main.async {
self.state = newState // add this
completion(success)
}
}
dataTask?.resume()
}
}`
Вместо старых переменныхisLoading
,hasSearched
, и searchResults
, этот код теперь только меняется state
.
Примечание: Вы не обновляете
state
напрямую, а вместо этого используете новую локальную переменнуюnewState
. Затем в конце, вDispatchQueue.main.async
блоке, вы передаете значениеnewState
toself.state
. Причина этого заключается в том, чтоstate
он должен быть изменен только основным потоком, иначе это может привести к неприятной и непредсказуемой ошибке, известной как состояние гонки.Когда у вас есть несколько потоков, пытающихся использовать одну и ту же переменную одновременно, приложение может делать неожиданные вещи и выходить из строя. В нашем приложении основной поток попытается использовать
search.state
для отображения счетчика активности в табличном представлении — и это может произойти одновременноURLSession
с обработчиком завершения, который работает в фоновом потоке. Мы должны сделать так, чтобы эти две нити не мешали друг другу!
Вот как работает новая логика:
Существует много вещей, которые могут пойти не так между выполнением сетевого запроса и анализом JSON. Установив значение newState
to .notSearchedYet
(которое удваивается как состояние ошибки) и success
to false
в начале обработчика завершения, вы предполагаете худшее — всегда хорошая идея при сетевом программировании — если нет доказательств обратного.
Это доказательство приходит, когда приложение может успешно проанализировать JSON и создать массив SearchResult
объектов. Если массив пуст, newState
становится .noResults
.
Самое интересное-это когда массив не пуст. После сортировки, как и раньше, вы это делаете newState = .results(searchResults)
. Это дает newState
значение.results
, а также связывает SearchResult
с ним массив объектов. Вам больше не нужна отдельная переменная экземпляра для отслеживания массива; объект массива внутренне привязан к значениюnewState
.
Наконец, вы копируете значение newState
into self.state
. Как я уже упоминал, это должно произойти в основном потоке, чтобы предотвратить условия гонки.
Обновите другие классы, чтобы использовать перечисление состояний
Это завершает изменения в Search.swift, но есть довольно много других мест в коде, которые все еще пытаются использовать Search
старые свойства.
BOS В SearchViewController.swiftзаменить tableView(_:numberOfRowsInSection:)
на:
func tableView( _ tableView: UITableView, numberOfRowsInSection section: Int ) -> Int { switch search.state { case .notSearchedYet: return 0 case .loading: return 1 case .noResults: return 1 case .results(let list): return list.count } }
Это довольно просто — вместо того, чтобы пытаться понять смысл отдельных isLoading
переменных,hasSearched
, иsearchResults
, это просто смотрит на значение from search.state
. Это switch
утверждение идеально подходит для подобных ситуаций.
Этот .results
случай требует немного большего объяснения. Поскольку .results``SearchResult
с ним связан массив объектов, вы можете привязать этот массив к временной переменнойlist
, а затем использовать эту переменную внутри корпуса , чтобы прочитать, сколько элементов находится в массиве. Вот как вы используете связанное значение. Этот шаблон, использующий switch
оператор для просмотра state
, станет очень распространенным в вашем коде.
Заменить tableView(_:cellForRowAt:)
на:
`func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
switch search.state {
case .notSearchedYet:
fatalError(«Should never get here»)
case .loading:
let cell = tableView.dequeueReusableCell(
withIdentifier: TableView.CellIdentifiers.loadingCell,
for: indexPath)
let spinner = cell.viewWithTag(100) as! UIActivityIndicatorView
spinner.startAnimating()
return cell
case .noResults:
return tableView.dequeueReusableCell(
withIdentifier: TableView.CellIdentifiers.nothingFoundCell,
for: indexPath)
case .results(let list):
let cell = tableView.dequeueReusableCell(
withIdentifier: TableView.CellIdentifiers.searchResultCell,
for: indexPath) as! SearchResultCell
let searchResult = list[indexPath.row]
cell.configure(for: searchResult)
return cell
}
}`
То же самое происходит и здесь. Различные if
утверждения были заменены утверждениями a switch
и case
для четырех возможностей.
Обратите внимание, что numberOfRowsInSection
возвращает 0 for.notSearchedYet
, и никакие ячейки никогда не будут запрошены. Но поскольку переключатель всегда должен быть исчерпывающим, вы также должны включить регистр для .notSearchedYet
in cellForRowAt
. Поскольку это была бы ошибка, если бы код когда-либо попал туда, вы можете использовать встроенную fatalError()
функцию, чтобы помочь поймать такую ситуацию.
Следующий шаг-этоtableView(_:willSelectRowAt:)
:
func tableView( _ tableView: UITableView, willSelectRowAt indexPath: IndexPath ) -> IndexPath? { switch search.state { case .notSearchedYet, .loading, .noResults: return nil case .results: return indexPath } }
Нажимать на строки можно только тогда, когда состояние есть .results
. Таким образом, для всех остальных случаев этот метод возвращается nil
. И в этом .results
случае вам не нужно связывать массив результатов, потому что вы не используете его здесь ни для чего.
И, наконец, перейдите prepare(for:sender:)
на:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if segue.identifier == "ShowDetail" { if case .results(let list) = search.state { let detailViewController = segue.destination as! DetailViewController let indexPath = sender as! IndexPath let searchResult = list[indexPath.row] detailViewController.searchResult = searchResult } } }
Здесь вы заботитесь только о .results
деле, так что писать целое switch
заявление-это чересчур. В подобных ситуациях вы можете использовать специальный if case
оператор для рассмотрения одного случая.
В LandscapeViewController.swift нужно внести еще одно изменение.
BOS Измените if firstTime
блок наviewWillLayoutSubviews()
:
`if firstTime {
firstTime = false
switch search.state {
case .notSearchedYet, .loading, .noResults:
break
case .results(let list):
tileButtons(list)
}
}`
При этом используется тот же шаблон, что и раньше. Если состояние есть .results
, то оно связывает массив SearchResult
объектов с временной константой list
и передает ее дальше tileButtons()
. Причина, по которой вы не используете if case
условие здесь, заключается в том, что в ближайшее время вы добавите дополнительный код к другим случаям. Но поскольку эти кейсы в настоящее время пусты, они должны содержать break
заявление.
Однако, когда несколько случаев имеют одно и то же действие, вы можете объединить их в одном case
операторе, как показано выше.
BOS Соберите и запустите, чтобы увидеть, работает ли приложение по — прежнему-оно должно работать!
Я думаю, что перечисления со связанными значениями-одна из самых интересных особенностей Swift. Здесь вы использовали их, чтобы упростить способ Search
выражения состояния. Без сомнения, вы найдете много других замечательных применений для них в ваших собственных приложениях!
Это хорошее время для фиксации ваших изменений.
Поверни меня кругом
Если вы поворачиваетесь в альбомную ориентацию во время поиска, приложение действительно должно показывать анимированный счетчик, чтобы пользователь знал, что происходит действие. Вы уже проверяете, viewWillLayoutSubviews()
каково состояние активного Search
объекта, так что это легко исправить.
Отображение индикатора активности в ландшафтном режиме
BOS В LandscapeViewController.swiftдобавьте новый метод для отображения индикатора активности.:
private func showSpinner() { let spinner = UIActivityIndicatorView(style: .large) spinner.center = CGPoint( x: scrollView.bounds.midX + 0.5, y: scrollView.bounds.midY + 0.5) spinner.tag = 1000 view.addSubview(spinner) spinner.startAnimating() }
Это создает новый UIActivityIndicatorView
объект, помещает его в центр экрана и начинает анимировать. Вы даете спиннеру тег 1000, так что вы можете легко удалить его с экрана, как только поиск будет завершен.
BOS In viewWillLayoutSubviews()
измените .loading
регистр в switch
операторе, чтобы вызвать этот новый метод – вам также придется переместить loading
регистр из комбинированной строки case:
case .loading: showSpinner()
BOS Запустите приложение. После начала поиска быстро поверните телефон в альбомную ориентацию. Теперь вы должны увидеть спиннер:

Спиннер указывает на то, что поиск все еще продолжается
Примечание: В новом методе вы добавляете
0.5
в позицию спиннераcenter
. Этот вид блесны имеет 37 точек в ширину и высоту, что не является четным числом. Если бы вы поместили центр этого вида точно в центр экрана в точке (284, 160), то он простирался бы на 18,5 точки в оба конца. Верхний левый угол этого спиннера будет находиться в координатах (265.5, 141.5), что сделает его размытым.Лучше всего избегать размещения объектов в дробных координатах. Добавляя 0,5 как к позиции X, так и к позиции Y, спиннер помещается в (266, 142), и все выглядит остро. Обратите на это внимание при работе со
center
свойством и объектами, имеющими нечетную ширину или высоту.
Скрыть ландшафтный счетчик при обнаружении результатов
Все это здорово, но спиннер не исчезает при получении фактических результатов поиска. Приложение никогда не уведомляет о LandscapeViewController
найденных результатах.
Существует множество способов сообщить LandscapeViewController
о том, что пришли результаты поиска, но давайте сделаем это просто.
BOS В LandscapeViewController.swiftдобавьте эти два новых метода:
`// MARK: — Helper Methods
func searchResultsReceived() {
hideSpinner()
switch search.state {
case .notSearchedYet, .loading, .noResults:
break
case .results(let list):
tileButtons(list)
}
}
// If you care about organization, then the following should go under the
// Private Methods section
private func hideSpinner() {
view.viewWithTag(1000)?.removeFromSuperview()
}`
Частный hideSpinner()
метод ищет представление с тегом 1000 — счетчик активности — и затем сообщает этому представлению удалить себя с экрана.
Вы могли бы сохранить ссылку на спиннер и использовать ее, но для такой простой ситуации, как эта, вы могли бы также использовать тег.
Поскольку ни у кого больше нет сильных ссылок на UIActivityIndicatorView
этот экземпляр , он будет освобожден. Обратите внимание, что вы должны использовать необязательную цепочку, потому viewWithTag()
что потенциально можете вернуться nil
.
searchResultsReceived()
Метод, конечно, должен быть вызван откуда-то, и это где-то есть SearchViewController
.
BOS В методе SearchViewController.swiftperformSearch()
добавьте следующую строку в замыкание нижеself.tableView.reloadData()
:
self.landscapeVC?.searchResultsReceived()
Последовательность событий здесь довольно интересна. Когда поиск начинаетсяLandscapeViewController
, объекта еще нет, потому что единственный способ начать поиск-это портретный режим.
Но к тому времени, когда будет вызвано замыкание, устройство может повернуться, и если это произойдетself.landscapeVC
, оно будет содержать действительную ссылку.
При вращении вы также дали новому LandscapeViewController
ссылку на активный Search
объект. Теперь вам просто нужно сказать ему, что результаты поиска доступны, чтобы он мог создавать кнопки и заполнять их изображениями.
Конечно, если вы все еще находитесь в портретном режиме к моменту завершения поиска, то self.landscapeVC
is nil
и вызов to searchResultsReceived()
будут просто проигнорированы из — за необязательной цепочки-вы могли бы использовать if let
здесь , чтобы развернуть значениеself.landscapeVC
, но необязательная цепочка имеет тот же эффект и короче для записи.
Попробуйте это сделать. Это очень хорошо работает, а?
Упражнение: Убедитесь, что сетевые ошибки также обрабатываются правильно, когда приложение находится в альбомной ориентации. Найдите способ создать или подделать сетевую ошибку и посмотрите, что происходит в ландшафтном режиме. Подсказка: если вы не хотите использовать кондиционер сетевого соединения, эта
sleep(5)
функция переведет ваше приложение в спящий режим на 5 секунд. Поместите это в обработчик завершения, чтобы дать себе время перевернуть устройство.
Ничего не найдено
Ты еще не закончил. Если совпадений не найдено, вы также должны сообщить об этом пользователю, если он находится в ландшафтном режиме.
BOS Во-первых, добавьте следующий метод в LandscapeViewController.swift:
`private func showNothingFoundLabel() {
let label = UILabel(frame: CGRect.zero)
label.text = «Nothing Found»
label.textColor = UIColor.label
label.backgroundColor = UIColor.clear
label.sizeToFit()
var rect = label.frame
rect.size.width = ceil(rect.size.width / 2) * 2 // make even
rect.size.height = ceil(rect.size.height / 2) * 2 // make even
label.frame = rect
label.center = CGPoint(
x: scrollView.bounds.midX,
y: scrollView.bounds.midY)
view.addSubview(label)
}`
Сначала вы создаете UILabel
объект и задаете ему текст и цвет — обратите внимание, что цвет является системным label
цветом, чтобы текст отображался правильно в любом виде. backgroundColor
Свойство имеет значениеUIColor.clear
, чтобы сделать метку прозрачной.
Вызов to sizeToFit()
указывает метке изменить размер до оптимального размера. Вы могли бы дать этикетке рамку, которая была бы достаточно большой для начала, но я нахожу это так же просто. Это также помогает, когда вы переводите приложение на другой язык, и в этом случае вы можете заранее не знать, насколько большой должна быть метка.
Единственная проблема заключается в том, что вы хотите центрировать метку в представлении, и, как вы видели раньше, это становится сложным, когда ширина или высота нечетны — то, что вы не обязательно знаете заранее. Поэтому здесь вы используете небольшой трюк, чтобы всегда заставлять размеры метки быть четными числами:
width = ceil(width/2) * 2
Если вы разделите такое число, как 11, на 2, то получите 5,5. ceil()
Функция округляет 5,5 до 6, а затем умножает на 2, чтобы получить конечное значение 12. Эта формула всегда дает вам следующее четное число, если исходное нечетное. Вам нужно сделать это только потому, что эти значения имеют тип CGFloat
. Если бы это были целые числа, вам не пришлось бы беспокоиться о дробных частях.
Примечание: Поскольку вы используете не жестко закодированное число, такое как 480 или 568, а
scrollView.bounds
для определения ширины экрана, код для центрирования метки работает правильно на всех размерах экрана.
BOS Внутри switch
оператора in viewWillLayoutSubviews()
вызовите новый метод из случая для.noResults
:
case .noResults: showNothingFoundLabel()
BOS Запустите приложение и найдите что-нибудь смешное (подойдет ewdasuq3sadf843). Когда поиск будет завершен, переключитесь на альбомную ориентацию.

Да, здесь тоже ничего не найдено
Он еще не работает должным образом, если вы переключаетесь в альбомную ориентацию во время поиска. Конечно, вам также нужно вложить некоторую логику searchResultsReceived()
.
BOS Измените switch
оператор в этом методе на:
switch search.state { case .notSearchedYet, .loading: break case .noResults: showNothingFoundLabel() case .results(let list): tileButtons(list) }
Теперь вы должны прикрыть все свои базы.
Всплывающее окно подробностей
Ландшафтный вид стал намного более функциональным после всех рефакторингов и изменений. Но есть еще одна вещь, которую нужно сделать. Ландшафтные результаты поиска-это не просто кнопки. Приложение должно показывать всплывающее окно сведений, когда вы нажимаете на элемент.
Этого довольно легко достичь. При добавлении кнопок вы можете указать им target-action — метод, который будет вызываться при получении события Touch Up Inside. Точно так же, как в Interface Builder, за исключением того, что теперь вы программно подключаете событие к методу action.
Показать всплывающее окно подробностей
BOS Во-первых, все еще в LandscapeViewController.swift добавьте метод, который будет вызываться при нажатии кнопки:
@objc private func buttonPressed(_ sender: UIButton) { performSegue(withIdentifier: "ShowDetail", sender: sender) }
Несмотря на то, что это метод действия, вы не объявили его как @IBAction
. Это необходимо только тогда, когда вы хотите подключить метод к чему-то в Interface Builder. Здесь вы устанавливаете соединение с помощью кода, так что вы можете пропустить @IBAction
аннотацию.
Также обратите внимание, что метод имеет @objc
атрибут — как вы узнали ранее с MyLocations, вам нужно пометить любой метод, идентифицированный через a#selector
, @objc
атрибутом. Таким образом, это , по-видимому, указывает на то, что вы будете вызывать этот новый метод с помощью a#selector
, не так ли?
Нажатие кнопки просто запускает переход, и через мгновение вы доберетесь до части перехода. Но сначала вы должны подключить кнопки к описанному выше методу.
BOS Добавьте следующие две строки в код создания кнопки вtileButtons()
:
button.tag = 2000 + index button.addTarget( self, action: #selector(buttonPressed), for: .touchUpInside)
Сначала вы даете кнопке тег, чтобы знать, какому индексу в .results
массиве соответствует эта кнопка. Это необходимо для того, чтобы передать правильный SearchResult
объект во всплывающее окно Detail.
Кроме того, если вы index
ранее заменили переменную в for
цикле подстановочным знаком из-за предупреждения компилятора Xcode, то сейчас самое время отменить это изменение.
Совет: Вы добавили 2000 в индекс, потому что тег 0 используется для всех представлений по умолчанию, поэтому запрос представления с тегом 0 может фактически вернуть представление, которого вы не ожидали. Чтобы избежать такой путаницы, вы просто начинаете считать с 2000 года.
Вы также сообщаете кнопке, что она должна вызвать buttonPressed()
метод, когда он будет нажат.
Затем добавьте prepare(for:sender:)
метод для обработки перехода.:
// MARK: - Navigation override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if segue.identifier == "ShowDetail" { if case .results(let list) = search.state { let detailViewController = segue.destination as! DetailViewController let searchResult = list[(sender as! UIButton).tag - 2000] detailViewController.searchResult = searchResult } } }
Это почти идентично prepare(for:sender:)
from SearchViewController
, за исключением того, что теперь вы получаете индекс SearchResult
объекта не из индексного пути, а из тега кнопки минус 2000.
Конечно, ничего из этого не сработает, если у вас действительно нет перехода в раскадровке.
BOS Перейдите к пейзажной сцене в раскадровке и перетащите элемент управления из желтого круга вверху в контроллер детального представления. Сделайте его настоящим модальным сегментом с идентификатором ShowDetail. Раскадровка теперь должна выглядеть так:

Раскадровка после подключения ландшафтного вида к всплывающему окну сведений
BOS Запустите приложение и проверьте его. Наверное, это выглядит так:

Всплывающее окно в ландшафтном режиме слишком велико
Исправьте всплывающее окно деталей
Хм … Это не совсем то, чего вы ожидали, не так ли?
Упражнение: Знаете ли вы, что пошло не так?
Ответ: Когда вы удалили ограничение ширины всплывающего окна, чтобы поддерживать действительно большие шрифты после добавления поддержки динамического типа, вы имели дело только с портретным режимом. В портретном режиме, как правило, даже в самом широком, всплывающее окно будет выглядеть нормально. Но не в ландшафтном режиме …
Есть несколько способов исправить это:
- Добавьте ограничение ширины назад, чтобы всплывающее окно всегда отображалось в разумном размере, будь то в портретном или альбомном режиме.
- Установите отдельные ограничения для ландшафтного режима для всплывающего окна, чтобы оно не было таким широким.
В то время как вариант № 1 проще, вариант № 2 улучшит работу приложения для каждой ориентации устройства. Итак, давайте рассмотрим вариант № 2.
Конечно, вы можете добавить выходы для соответствующих ограничений и изменить их в зависимости от ориентации устройства. Но это то, с чем вы уже знакомы. Давайте изучим другой способ, который научит вас устанавливать ограничения на основе признаков :]
BOS Откройте раскадровку, выберите Всплывающее окно, перейдите в инспектор размеров, выберите Выравнивание, ведущее к: ограничение в безопасную область, и дважды щелкните его, чтобы получить редактор ограничений.:

Редактор ограничений
BOS Нажмите кнопку + (плюс) рядом с константой, чтобы добавить пользовательское значение константы на основе нескольких факторов, доступных из открывшегося нового всплывающего окна.:

Варианты вариации
Вы можете добавить варианты, основанные на классах размеров, о которых вы уже узнали при настройке ландшафтного вида. Новое диалоговое окно предварительно настроено для текущего выбранного устройства и ориентации, выбранных с помощью панели инструментов IB.
Таким образом, на данный момент вы бы добавили новый вариант для iPhone SE в ландшафтном режиме, если бы использовали значения по умолчанию.
BOS Нажмите кнопку Добавить вариацию.
Теперь вы должны получить новое значение в разделе Константа для конкретного варианта класса размеров, который вы запросили:

Новое значение вариации
BOS Введите 150 в качестве нового значения – обратите внимание, как изменяется ведущее ограничение на холсте для вашего всплывающего представления.
Аналогично, установите новое значение вариации 150 и для конечного ограничения.
Теперь ваше всплывающее окно выглядит гораздо компактнее в Interface Builder. Но как насчет других размерных классов?
Используйте панель инструментов Interface Builder, чтобы переключить предварительный просмотр на более крупное устройство, например iPhone 11 Pro Max.
Вы заметите, что всплывающее окно снова слишком широкое. Это связано с тем, что iPhone 11 Pro Max (и аналогичные устройства) имеют обычный размерный класс для высоты в ландшафтном режиме.
BOS Добавьте два новых варианта — один для ведущего, а другой для замыкающего — и для этого размерного класса. Не стесняйтесь регулировать расстояние по своему усмотрению, если вы считаете, что значения 150 недостаточно.
Теперь ваше всплывающее окно деталей выглядит намного лучше в ландшафте:

Финальное всплывающее окно в ландшафтном режиме
Скрыть всплывающее окно при вращении
Круто! Но что происходит, когда вы поворачиваетесь назад к портрету с появлением подробного всплывающего окна? К сожалению, он никуда не денется. Вам нужно указать экрану сведений, чтобы он закрывался, когда ландшафтный вид скрыт.
BOS В SearchViewController.swifthideLandscape(with:)
добавьте следующие строки в закрытие animate(alongsideTransition:)
анимации:
if self.presentedViewController != nil { self.dismiss(animated: true, completion: nil) }
В выводе консоли вы должны увидеть, что DetailViewController
он правильно освобождается при повороте обратно в портретное положение.
BOS Если вы довольны тем, как работает код, то давайте зафиксируем его. Если вы тоже сделали ветвь, то объедините ее обратно в основную ветвь.
Вы можете найти файлы проекта для этой главы в разделе 40-Рефакторинг в папке исходного кода.