Привет! Дизайнеры рисуют приложения с красивыми кнопочками, тенями, анимациями, градиентами и сложными переходами между экранами. К сожалению, такие дизайны нелегко превращать в рабочие приложения. Можно ли облегчить нашу работу? Разберемся на примере приложений, получивших награды Apple за дизайн: Auxy, Streaks и Zova.
Статья предназначена только для образовательных целей. Пожалуйста, не используйте исходный код в других целях.
Для создания интерфейса я возьму библиотеку Macaw, которая описывает графику в виде верхнеуровневых объектов сцены. Советую заглянуть в «Getting Started», если еще не видели. Погнали!
Streaks
Streaks – To-Do список, воспитывающий хорошие привычки: читать каждый день и не забывать чистить зубы. Определим графические компоненты и связи между ними.
Первый элемент рисует сетку 2 на 3: X-координата первой колонки ноль, второй – половина ширины экрана. Y-координата зависит от номера строки и равна row*screen.width/2
(ячейки квадратные).
Элемент «streak» включает в себя контент и заголовок. По клику пользователь переключает контент между логотипом, календарем и статистикой. Функцию переключения сделаем позже.
// 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).
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()
В календарь входит название месяца, дня недели и статусы дней: «сделано», «пропущено» или «предстоит сделать». «Сделано» и «предстоит» отображаются простыми кругами. «Пропущено» – две пересекающиеся линии черного цвета.
// 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% отступ с каждой стороны).
Бар содержит четыре элемента: два текста и два прямоугольника со скругленными углам. Один прямоугольник заполненный.
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.
Auxy Studio
Auxy – студия для создания музыки и битов в телефоне. Синие квадраты – звуки, пользователь добавляет и удаляет их по тапу. При нажатии на «Play» белая линия движется сверху вниз и при пересечении со звуком воспроизводит его.
Auxy экран состоит из четырех основных компонентов: кнопка «Play», «LineRunner», звуки и сетка на фоне.
Сетка содержит горизонтальные и вертикальные линии. Каждая четвертая горизонтальная линия выделяется. Размер сетки 8x16: ширина ячейки screen.width / columns
и высота screen.height / rows
.
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). Любой элемент сцены легко масштабируется до нужных размеров. Векторная графика – мощь!
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» линия циклически движется сверху вниз. При пересечении со звуком мы подсвечиваем его. Зная время анимации рассчитываем, когда линия пересечет звук. Это значение – задержка анимации подсвечивания.
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.
Zova
Zova – персональный фитнес тренер. В него входит две компоненты: круговая диаграмма в центре и бар внизу экрана.
В круговую диаграмму входит восемь кругов на фоне, один заполненный круг в центре, текущий результат и emoji иконка.
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).
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» текста и еще одного прямоугольника с градиентным цветом.
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()
.
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.
Summary
Оживить дизайн, а тем более дизайн, получивший награду Apple, – нелегкая работа. Разработчики тратят много времени делая собственные графические элементы и анимации, работающие на устройствах различных размеров. Эту работу можно упростить, используя инструменты, предоставляющие правильные абстракции и удобное API. Macaw – одна из таких библиотек, которая позволяет сфокусироваться на главном.
Автор: Zapletnev