Реализация интерфейса с выдвижной панелью в iOS приложении
В нашем случае интерфейс разрабатывался под утилиту Vehicle Location Tracker, у которой в качестве основного экрана выступает карта с отмеченными локациями транспортных средств, а выдвижное меню предоставляет пользователю возможность работать с конкретной локацией. Далее весь процесс будет рассматриваться на примере этого приложения.
Основное назначение шторки в Vehicle Location Tracker — отображать информацию о выбранной парковке. В зависимости от сиюминутных потребностей пользователя она может быть скрыта, может отображаться на дисплее в виде верхней панели (обычного или расширенного вида) или же выдвигаться полностью, показывая весь набор инструментов редактирования.
Выглядит это примерно так:
Начали мы с того, что нарезали вьюшки по типам и разбросали их по отдельным XIB. Дальше элементы просто собирались как конструктор. Задачу облегчало то, что возможности изменения порядка положения вьюшек не подразумевалось, только изменение некоторых расстояний.
Взаимодействие строилось между основным контроллером и его контейнером, в котором и находилась шторка. Основной контроллер имел ссылку на контроллер контейнера. Обратное же взаимодействие осуществлялось через делегат.
Логика работы шторки такова. Есть четыре состояния основного окна:
— добавление новой парковки;
— редактирование существующей;
— обычное рабочее состояние с выделенной парковкой;
— состояние, когда ничего не выбрано.
enum MapState : Int {
case New
case Edit
case Normal
case Empty
}
У самой же шторки гораздо более разнообразный комплект состояний — в общей сложности их семь. Разделение парковок на только что добавленные и редактируемые для шторки не проводится, но зато у режимов Normal и Edit, помимо базовых версий, появляются еще и расширенные. Кроме того, добавляется состояние «в движении» с параметром «последнее фиксированное положение»:
indirect enum MenuState {
case Empty
case Hide(previous: MenuState)
case Normal
case Advanced
case EditNormal
case EditAdvanced
case Motion(previous: MenuState)
}
Передача состояния от MapState в MenuState выглядит следующим образом:
class MapViewController: UIViewController {
var currentState = StageMap.Zero {
willSet {
slideMenuVC.currentParentState = newValue
}
}
}
class SlideMenuViewController: UIViewController {
var currentParentState = StageMap.Zero {
willSet {
switch newValue {
case .Empty:
updateVisibleOfViews(toState: .Empty)
animateMove(toState: .Empty)
case .Normal:
updateVisibleOfViews(toState: .Normal)
animateMove(toState: .Normal)
case .New:
updateVisibleOfViews(toState: .EditNormal)
updateHeightOfTagsView()
animateMove(toState: .EditNormal)
case .Edit:
updateVisibleOfViews(toState: .EditNormal)
updateHeightOfTagsView()
animateMove(toState: .EditNormal)
}
}
}
}
Само движение шторки осуществлялось за счет UIView.animateWithDuration и CGAffineTransformMakeTranslation.
Отметим, что изменение состояния MenuState никак не влияет на MapState (и на том спасибо). Есть автоматическое переключение состояний по изменению MenuState в основном контроллере, а есть внутренние изменения шторки (через UITapGestureRecognizer или UIPanGestureRecognizer). При этом то, что происходит «снаружи», по умолчанию имеет больший приоритет.
Теперь немного о работе внутренних изменений шторки. Добавляем рекогнайзеры:
func addGesturesToView() {
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(SlideMenuViewController.tapGestureHandler(_:)))
actionView.addGestureRecognizer(tapGesture)
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(SlideMenuViewController.panGestureHandler(_:)))
actionView.addGestureRecognizer(panGesture)
}
и их реализацию:
func tapGestureHandler(recognizer:UITapGestureRecognizer) {
var state = MenuState.Hide
var canMove = true
switch currentState {
case .Hide(previous: .Normal),
.Hide(previous: .UpNormalAdvanced):
toState = .Normal
case .Hide(previous: .EditNormal),
.Hide(previous: .EditAdvanced):
toState = .EditNormal
case .Normal:
toState = .Hide(previous: .Normal)
case .Advanced:
toState = .Normal
case .EditAdvanced:
toState = .EditNormal
case .EditNormal:
toState = .EditAdvanced
case .Motion:
canMove = false
default: break
}
updateVisibleOfViews(toState: state)
if canMove {
animateMove(toState: state)
}
}
func panGestureHandler(recognizer:UIPanGestureRecognizer) {
switch recognizer.state {
case .Began:
currentState = .Moved(previous: currentState)
case .Changed:
//двигаем шторку за пальцем
case .Ended, .Cancelled:
//проверяем какой состояние было до этого .Moved(previous: lastState)
//и сравниваем начальные и конечные координаты, чтобы знать куда дотянуть шторку после отрыва пальца
}
}
Следующий пункт программы — расчет размеров и состояний. Панель тэгов и панель стандартного состояния имеют плавающую высоту, поэтому для корректного расчета размеров элемента вьюшки по запросу отдавали нужное значение, вычисленное с учетом количества и объема контента.
Пример расчета высоты для контента collectionView:
func getContentHeight() -> CGFloat {
let amountOfItems = tagCollectionView.numberOfItemsInSection(0)
guard amountOfItems > 0 else {
return kDefaultCollectionHeight
}
let indexPath = NSIndexPath(forItem: amountOfItems - 1, inSection: 0)
guard let attributes = tagCollectionView.collectionViewLayout.layoutAttributesForItemAtIndexPath(indexPath) else {
return kDefaultCollectionHeight
}
let collectionViewContentHeight = attributes.frame.origin.y + attributes.frame.size.height
return collectionViewContentHeight
}
Не все размеры необходимо прописывать вручную, кое-где мы автоматизировали процесс при помощи софта.
В работе над Vehicle Location Tracker нам очень пригодился Sketchode — инструмент, о котором мы узнали здесь же, на Хабре. Для тех кто не читал: речь идет о программе, которая позволяет разработчику изучать и «разбирать» макет из Sketch для собственных нужд, при этом не внося в него никаких изменений. И волки сыты, и дизайнер спокоен.
Sketchode оказался нам полезен в двух отношениях. Во-первых, он точно и наглядно показывает расстояния между любыми элементами.
Во-вторых, очень удобно реализован экспорт элементов. Иконки транспортных средств, которые должны были встать в центре канваса, у нас все оказались разными по размерам, хотя использовались в одной и той же вьюшке. Возможность задавать размеры непосредственно при экспорте ощутимо сэкономила нам время на выравнивании.
В плане интерфейса выигрышным является то, что можно открыть несколько окон с артбордами и спокойно между ними переключаться, как видно на скрине, — не нужно метаться по всему списку артбордов в поисках того, с которым только что взаимодействовал.
Основные этапы работы с выдвижной панелью мы рассмотрели. Напоследок еще пара мелочей, которые могут пригодиться при работе. Параллельно разрабатывая и на obj-c и на swift, мы иногда искренне умиляемся, какие удобные штуки можно делать на swift. Вот, например:
indirect enum MapButtonStage {
case Disable(previous: MapButtonStage)
case Off
case On
}
— и все возможные состояния кнопки описаны, причем enum сам запомнит, в каком моде была кнопка, если мы ее принудительно заблокируем:
enum PinColor : Int {
case Red
case Violet
case Green
case Blue
case Black
case Yellow
func getColor() -> UIColor {
switch self {
case .Violet:
return UIColor.colorFromHexString("#8E44AD")
case .Red:
return UIColor.colorFromHexString("#FF3824")
case .Green:
return UIColor.colorFromHexString("#16A085")
case .Blue:
return UIColor.colorFromHexString("#0076FF")
case .Black:
return UIColor.colorFromHexString("#44464E")
case .Yellow:
return UIColor.colorFromHexString("#F5A623")
}
}
var descriptionImage: String {
switch self {
case .Violet:
return "_purple"
case .Red:
return "_red"
case .Green:
return "_green"
case .Blue:
return "_blue"
case .Black:
return "_grey"
case .Yellow:
return "_yellow"
}
}
}
А тут вообще красота: мы в один enum поместили и ассоциированный UIColor, и кусок имени для подгрузки нужных картинок из ассетов. Можно, конечно, хранить все эти имена в одном месте, но тогда добавлять новые будет неудобно и некрасиво.
Чтобы не возникало проблем с компоновкой имен, делаем структуру:
struct ImageName {
var color: PinColor
var category: PinCategory
func imageName() -> String {
return category.descriptionImage + color.descriptionImage;
}
}
и вызываем ее:
let name = ImageName(pinColor: color, pinCategory: category).imageName()
Готово!
Вот какие навыки мы получили для себя в ходе первого опыта создания шторки. Надеемся, наши наблюдения будут полезны и другим разработчикам. Спасибо за внимание!