Повторяем дизайн приложений, получивших награду Apple

в 11:51, , рубрики: iOS, swift, разработка под iOS

Привет! Дизайнеры рисуют приложения с красивыми кнопочками, тенями, анимациями, градиентами и сложными переходами между экранами. К сожалению, такие дизайны нелегко превращать в рабочие приложения. Можно ли облегчить нашу работу? Разберемся на примере приложений, получивших награды Apple за дизайн: Auxy, Streaks и Zova.

image

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

Для создания интерфейса я возьму библиотеку Macaw, которая описывает графику в виде верхнеуровневых объектов сцены. Советую заглянуть в «Getting Started», если еще не видели. Погнали!

Streaks

Streaks – To-Do список, воспитывающий хорошие привычки: читать каждый день и не забывать чистить зубы. Определим графические компоненты и связи между ними.

image

Первый элемент рисует сетку 2 на 3: X-координата первой колонки ноль, второй – половина ширины экрана. Y-координата зависит от номера строки и равна row*screen.width/2 (ячейки квадратные).

Элемент «streak» включает в себя контент и заголовок. По клику пользователь переключает контент между логотипом, календарем и статистикой. Функцию переключения сделаем позже.

image

// grid cell (column, row)
let streak = Group(
  contents: [self.streak(text: "MEDIUM", imageName: "medium")],
  place: Transform.move(
      dx: Double(screen.width / 2) * column,
      dy: Double(screen.width / 2) * row
  )
)

// streak: content + title
func streak(text: String, imageName: String) -> Group {
  let title = Text(
      text: text,
      font: Font(name: fontName, size: 14),
      fill: Color.white,
      align: .mid,
      place: Transform.move(
          dx: Double(screen.width) / 4,
          dy: 0.7 * Double(screen.width) / 2
      )
  )

  let streakContent = Group()
  return [streakContent, title].group()
}

Займемся логотипом, календарем и статистикой. Логотип состоит из дуги длиной 2*PI и картинки в центре. Радиус зависит от размера экрана. Элементы позиционируются относительно точки (0,0).

image

let ellipse = Ellipse(cx: radius, cy: radius, rx: radius, ry: radius)
let border = Shape(
    form: Arc(ellipse: ellipse, extent: 2 * M_PI),
    fill: background,
    stroke: Stroke(fill: Color(val: 0x744641), width: 8)
)

let image = UIImage(named: imageName)!
let logoImage = Image(
    src: imageName,
    place: Transform.move(
        // move image in the point (radius, radius)
        dx: radius - Double(image.size.width) / 2,
        dy: radius - Double(image.size.height) / 2
    )
)

let logo = [border, logoImage].group()

В календарь входит название месяца, дня недели и статусы дней: «сделано», «пропущено» или «предстоит сделать». «Сделано» и «предстоит» отображаются простыми кругами. «Пропущено» – две пересекающиеся линии черного цвета.

image

// input parameters
let x = width / 6 * Double(column)
let y = Double(row) * 15

// skip day
let line1 = Line(x1: x - 4, y1: y, x2: x + 4, y2: y + 8)
let line2 = Line(x1: x - 4, y1: y + 8, x2: x + 4, y2: y)
let stroke = Stroke(fill: Color.black, width: 4)
let cross = [
  Shape(form: line1, stroke: stroke), 
  Shape(form: line2, stroke: stroke)
].group()

// done day
let done = Shape(
  form: Circle(cx: x, cy: y + radius, r: radius),
  fill: doneColor
)

// future day
let future = Shape(
  form: Circle(cx: x, cy: y + radius, r: radius),
  fill: lightColor
)

Статистика – группа из трех баров с разными Y-координатами. Y-координата зависит от номера бара, а ширина бара от размера экрана: в нашем случае это 80% от половины ширины (по 10% отступ с каждой стороны).

Бар содержит четыре элемента: два текста и два прямоугольника со скругленными углам. Один прямоугольник заполненный.

image

let bar = Group(contents: [
  Text(
      text: "LAST 30 DAYS",
      font: Font(name: fontName, size: 12),
      fill: Color.white,
      align: .min
  ), Text(
      text: "42%",
      font: Font(name: fontName, size: 12),
      fill: lightColor,
      align: .max,
      place: Transform.move(dx: width, dy: 0)
  ), Shape(
      form: Rect(x: 0, y: 18, w: width, h: 10).round(r: 2),
      fill: lightColor
  ), Shape(
      form: Rect(x: 0, y: 18, w: width * 0.42, h: 10).round(r: 2),
      fill: Color.white
  )
]

Закончили с контентом, перейдем к анимации переключения. Тап по streak’у переключает и центрирует новый контент (элементы отличаются шириной). Первая анимация скрывает старый контент, вторая показывает новый.

func animateStreak(newContent: Group, margin: Int) {
    let animation = streakContent.opacityVar.animation(to: 0.0, during: 0.1)
    animation.onComplete {
        streakContent.contents = newContent
        streakContent.place = Transform.move(dx: margin / 2, dy: 0)
        streakContent.opacityVar.animation(to: 1.0, during: 0.1).play()
    }
    animation.play()
}

Остался последний штрих. Запускаем анимацию дуги от 1.5*PI до 3.5*PI при создании новой привычки. Здесь подробнее читаем о content-animation. В конце анимации открываем «Add Task» контроллер.

streak.onTap { tapEvent in
  let animation = group.contentsVar.animation({ t in
    let animatedShape = Shape(
      form: Arc(ellipse: ellipse, shift: 1.5 * M_PI, extent: 2 * M_PI * t),
      stroke: Stroke(fill: Color.white, width: 8)
    )
    return [animatedShape]
  }, during: 0.5).easing(Easing.easeInOut)

  animation.onComplete {
    // open task controller
  }
  animation.play()
}

Результат

Не забываем чистить зубы и смотрим Xcode проект на GitHub.

image

Auxy Studio

Auxy – студия для создания музыки и битов в телефоне. Синие квадраты – звуки, пользователь добавляет и удаляет их по тапу. При нажатии на «Play» белая линия движется сверху вниз и при пересечении со звуком воспроизводит его.

image

Auxy экран состоит из четырех основных компонентов: кнопка «Play», «LineRunner», звуки и сетка на фоне.

Сетка содержит горизонтальные и вертикальные линии. Каждая четвертая горизонтальная линия выделяется. Размер сетки 8x16: ширина ячейки screen.width / columns и высота screen.height / rows.

image

let columns = Array(0..<dimension.0).map { column in
    let x = cell.w * column
    return Shape(
        form: Line(x1: x, y1: 0, x2: x, y2: size.h),
        stroke: stroke,
        opacity: 0.2
    )
}.group()

let rows = Array(0..<dimension.1).map { row in
    let y = cell.h * row
    return Shape(
        form: Line(x1: 0, y1: y, x2: size.w, y2: y),
        stroke: stroke,
        opacity: row % 4 == 0 ? 1 : 0.2
    )
}.group()

При тапе на экран добавляем звук на сетку. Колонка и строчка вычисляются из координат тапа. Звук содержит два прямоугольника: передний белого цвета с прозрачностью 0.0 и задний синего цвета. Передний прямоугольник загорается, когда линия пересекает звук.

let column = floor(tapLocation.x / cellSize.w)
let row = floor(tapLocation.y / cellSize.h)
let rect = Rect(w: cellSize.w, h: cellSize.h)

let background = Shape(form: rect, fill: Color.rgb(r: 4, g: 112, b: 215))
let foreground = Shape(form: rect, fill: Color.white, opacity: 0.0)
let sound = Group(
    contents: [background, foreground],
    place: Transform.move(
        dx: column * cellSize.w,
        dy: row * cellSize.h
    )
)

Кнопка «Play» – самый сложный элемент. Два статических элемента: заполненный круг и дуга с отступом 0.05 возле PI/2. «Play» состоит из трех точек: (-1, 2), (2, 0), (-1, -2), «Stop» из четырех: (-2, 2), (2, 2), (2, -2), (-2, -2). Любой элемент сцены легко масштабируется до нужных размеров. Векторная графика – мощь!

image

let border = Shape(
    form: Arc(
        ellipse: Ellipse(rx: radius, ry: radius),
        shift: -M_PI / 2 + 0.05,
        extent: 2 * M_PI - 0.1
    ),
    stroke: Stroke(fill: Color.rgba(r: 219, g: 222, b: 227, a: 0.3), width: 2.0)
)

let circle = Shape(form: Circle(r: 25.0), fill: сolor)

let playButton = Shape(
    form: MoveTo(x: -1, y: 2).lineTo(x: 2, y: 0)
        .lineTo(x: -1, y: -2).close().build(),
    fill: Color.rgb(r: 46, g: 48, b: 58),
    place: Transform.scale(sx: 5.0, sy: 5.0)
)

let stopButton = Shape(
    form: MoveTo(x: -2, y: 2).lineTo(x: 2, y: 2)
        .lineTo(x: 2, y: -2).lineTo(x: -2, y: -2).close().build(),
    fill: Color.rgb(r: 46, g: 48, b: 58),
    place: Transform.scale(sx: 4.0, sy: 4.0)
)

let buttons = [[playButton], [stopButton]]
let buttonGroup = Group(contents: buttons[0])

let button = Group(contents: [border, circle, buttonGroup])

Когда пользователь нажимает «Play»:

  • Заменяем «Play» на «Stop» или обратно
  • Запускаем циклическую анимацию дуги: длина анимируется с -PI/2+0.05 до 3*PI/2–0.1

button.onTap { tapEvent in
  // change button content
  let index = buttons.index { $0 == buttonGroup.contents }!
  buttonGroup.contents = buttons[(index + 1) % buttons.count]

  if index == 0 {
     play()
  } else {
      // if stop pressed
      contentAnimation.stop()
      // hide animation group
      animationGroup.opacityVar.animation(to: 0.0, during: 0.1).play()
  }
}

func play() {
  contentAnimation = animationGroup.contentsVar.animation({ t in
      let shape = Shape(
          form: Arc(
              ellipse: Ellipse(rx: radius, ry: radius),
              shift: -M_PI / 2 + 0.05,
              extent: max(2 * M_PI * t - 0.1, 0)
          ),
          stroke: Stroke(fill: Color.white, width: 2)
      )
      return [shape]
  }, during: time).cycle()
  contentAnimation.play()
}

При нажатии на «play» линия циклически движется сверху вниз. При пересечении со звуком мы подсвечиваем его. Зная время анимации рассчитываем, когда линия пересечет звук. Это значение – задержка анимации подсвечивания.

image

let line = Shape(
  form: Line(x1: 0, y1: 0, x2: size.w, y2: 0),
  stroke: Stroke(fill: Color.rgba(r: 219, g: 222, b: 227, a: 0.5), width: 1.0)
)

func run(time: Double) {
  let lineAnimation = line.placeVar.animation(
      to: Transform.move(dx: 0, dy: screen.height),
      during: time
  ).easing(Easing.linear)

  let hightlight = sounds.map { sound -> Animation in
      return sound.hightlight().delay(sound.place.dy / screen.height * time)
  }.combine()

  let runAnimation = [soundsAnimation, lineAnimation].combine().cycle()
  runAnimation?.play()
}

Результат

Создаём музыку и смотрим Xcode проект на GitHub.

image

Zova

Zova – персональный фитнес тренер. В него входит две компоненты: круговая диаграмма в центре и бар внизу экрана.

image

В круговую диаграмму входит восемь кругов на фоне, один заполненный круг в центре, текущий результат и emoji иконка.

image

let mainCircle = Shape(
    form: Circle(r: 60), fill: mainColor,
    stroke: Stroke(fill: Color.white, width: 1.0)
)

let score = Text(
    text: "3",
    font: Font(name: lightFont, size: 40), fill: Color.white,
    align: .mid, baseline: .mid
)

let icon = Text(
    text: "молния",
    font: Font(name: regularFont, size: 24), fill: Color.white,
    align: .mid, place: Transform.move(dx: 0.0, dy: 30.0)
)

let shadows = [
    Point(x: 0, y: 35), Point(x: -25, y: 25), Point(x: 25, y: 25), Point(x: 25, y: -25),
    Point(x: -25, y: -25), Point(x: -40, y: 0), Point(x: 40, y: 0), Point(x: 0, y: -35)
].map { place in
    return Shape(
        form: Circle(r: 40), fill: Color.white.with(a: 0.8),
        place: Transform.move(dx: place.x, dy: place.y)
    )
}.group()

let acivityCircle = Group(contents: [shadows, mainCircle, score, icon])

Тап в круговую диаграмму отображает по кругу доступные emoji иконки. Если расстояние от центра до иконки d, то координаты иконки (cos(alpha) * d, sin(alpha) * d). По умолчанию меню выбора иконок скрыто (прозрачность 0.0).

image

let data = ["лыжник", "мяч", "молния"] // хабр сходит с ума от emoji
let emojis = data.enumerated().map { (index, item)  -> Group in
  let shape = Shape(form: Circle(r: 20), fill: Color.white)

  let icon = Text(
      text: item,
      font: Font(name: regularFont, size: 14),
      fill: Color.white,
      align: .mid,
      baseline: .mid
  )

  return Group(
      contents: [shape, icon],
      place: emojiPlace(index: index, d: 20.0),
      opacity: 0.0
  )
}.group()

func emojiPlace(index: Int, d: Double) -> Transform {
    let alpha = 2 * M_PI / 10.0 * Double(index)
    return Transform.move(
        dx: cos(alpha) * d,
        dy: sin(alpha) * d
    )
}

Бар – группа, состоящая из легенды и сегментов. Легенда состоит из прямоугольника со скругленными углами, «low» текста и еще одного прямоугольника с градиентным цветом.

image

let border = Shape(
  form: Rect(w: 80, h: 30).round(r: 16.0),
  fill: Color.white
)

let text = Text(
  text: "Low",
  font: Font(name: regularFont, size: 20), fill: mainColor,
  align: .mid, baseline: .mid,
  place: Transform.move(dx: 40, dy: 15)
)

let line = Shape(
  form: Rect(x: 20, y: 30, w: 2, h: 40),
  fill: LinearGradient(
      degree: 90,
      from: Color.white.with(a: 0.8),
      to: mainColor
  )
)
let legend = [border, text, line].group()

Сегмент состоит из прямоугольника и текста нам ним. X-координата элементов равна нулю. X-координата сегмента зависит от его номера. У последнего сегмента градиентный цвет.

let text = Text(
    text: text,
    font: Font(name: regularFont, size: 12), fill: Color.white,
    align: .min, baseline: .alphabetic,
    place: Transform.move(dx: 0, dy: -5)
)

let rect = Shape(
    form: Rect(w: width, h: 8),
    fill: !last ? color : gradient
)

let bar = [text, rect].group()

У легенды есть «прыгающий» эффект: она не спеша двигается вверх и вниз по вертикальной оси.

let jumpAnimation = legend.placeVar.animation(
    to: Transform.move(dx: 0.0, dy: -8.0),
    during: 2.0
).autoreversed().cycle()

Вернемся к анимации. При тапе на круговую диаграмму запускаем несколько анимаций одновременно:

  • Скрываем бар и верхние надписи
  • Уменьшаем фоновые круги
  • Поднимаем и скрываем текст с текущим результатом
  • Поднимаем и увеличиваем иконку emoji
  • Показываем и перемещаем от центра доступные emoji иконки

Хорошие новости! Нам не нужно беспокоиться об обратной анимации, она доступна автоматически: у любой анимации есть метод reverse().

image

let during = 0.5

let hideAnimation = [
    bar.opacityVar.animation(to: 0.0, during: during),
    texts.opacityVar.animation(to: 0.0, during: during)
].combine()

let emojisAnimation = emojis.contents.enumerated().map { (index, node) in
    return [
        node.opacityVar.animation(to: 1.0, during: during),
        node.placeVar.animation(
            // new emoji position
            to: emojiPlace(index: index, d: 120.0),
            during: during
        )
    ].combine()
}.combine()

let circleAnimation = [
    shadows.placeVar.animation(to: Transform.scale(sx: 0.5, sy: 0.5), during: during),
    score.placeVar.animation(to: Transform.move(dx: 0, dy: -20), during: during),
    score.opacityVar.animation(to: 0.0, during: during),
    icon.placeVar.animation(to: Transform.move(dx: 0, dy: -30).scale(sx: 2.0, sy: 2.0), during: during),
].combine()

let animation = [hideAnimation, emojisAnimation, circleAnimation].combine()
let reverseAnimation = animation.reverse()

Результат

Занимаемся спортом и смотрим Xcode проект на GitHub.

image

Summary

Оживить дизайн, а тем более дизайн, получивший награду Apple, – нелегкая работа. Разработчики тратят много времени делая собственные графические элементы и анимации, работающие на устройствах различных размеров. Эту работу можно упростить, используя инструменты, предоставляющие правильные абстракции и удобное API. Macaw – одна из таких библиотек, которая позволяет сфокусироваться на главном.

Автор: Zapletnev

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js