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



В нашем случае интерфейс разрабатывался под утилиту 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()

Готово!

Вот какие навыки мы получили для себя в ходе первого опыта создания шторки. Надеемся, наши наблюдения будут полезны и другим разработчикам. Спасибо за внимание!
Поделиться с друзьями
-->

Комментарии (6)


  1. AnthonyBY
    17.11.2016 16:13

    спасибо за статью.

    Если не трудно, расскажите пожалуйста как вы сделали такую анимацию


    1. EverydayTools
      17.11.2016 16:40

      Спасибо за комментарий! Вы про какую анимацию хотели уточнить? Если про само движение шторки, то оно осуществлялось за счет UIView.animateWithDuration и CGAffineTransformMakeTranslation.


  1. markquincy
    18.11.2016 07:25

    Всего бы ничего, но вы не подумали о том, что пользователи не смогут дотянуться до вашего экрана на 6-ке, в том числе plus


    1. EverydayTools
      18.11.2016 07:49

      Тем не менее у 6ки есть такая же шторка: как-то ей люди пользуются, просто обычно двумя руками, ну либо исхитряются одной (если палец подвижный), те же нотификации еще менее удобно одной рукой мэнэджить, чем пользовать шторку.


      1. markquincy
        18.11.2016 12:52

        Опираетесь на плохие интерфейсы в своих Юзер Кейсах? Ну как знаете…


        1. markquincy
          18.11.2016 12:57

          Более того, в iOS нотификации появляются по свайпу вниз, а у вас как я понимаю именно оттягивание.Предвещаю батхерт всех пользователей