Разработка
Почему ваше приложение выглядит дёшево (и дело не в цветах)
Исправление дешёвого ощущения приложения почти никогда не означает «добавить больше анимации». Нужно примерно то же количество анимации — просто она должна честно подчиняться физике.
На прошлой неделе кто-то прислал мне своё приложение и спросил, почему оно не ощущается так же, как моё. Я открыл его, ожидая увидеть обычный набор проблем. Но их не было. Цветовая палитра была аккуратной. Типографика — настоящей, она не была случайным набором размеров шрифта. Отступы выдержаны буквально до пикселя. На макете в Figma, если поставить его рядом с чем-то из того, что выпускал я, оно смотрелось бы вполне достойно.
Потом я нажал кнопку Add. Ничего не произошло. Я нажал ещё раз, чтобы убедиться, что мне не показалось. Надпись потускнела, возможно, процентов на пять, а сама кнопка в форме капсулы осталась совершенно неподвижной, как будто задумалась.
Я видел достаточно таких случаев, чтобы сразу узнать закономерность. Разработчик сделал действительно красивое приложение, но пропустил те самые 200 миллисекунд после того, как палец касается стекла. А ведь именно эту часть приложения пользователь буквально ощущает.
Он хотел, чтобы я раскритиковал цвета.
Но цвета вообще не были проблемой.
Давайте разберёмся.
Миф, который повторяют все
Вот миф, в который верит почти каждый разработчик, которого я знаю:
Моё приложение выглядит устаревшим. А значит мне нужны более красивые цвета, лучший шрифт и более мягкие скругления углов.
И вот вы заменяете синий цвет на градиент. Покупаете вариативный шрифт. Увеличиваете радиус скругления с 8 до 16. Выпускаете обновление. И приложение всё равно ощущается как школьный проект, которому надели красивую рубашку.
Причина проста. Ощущение дешевизны — это не визуальное свойство. Это свойство поведения. Цветовую палитру оценивают по скриншоту. Приложение оценивают в руке.
Почему мы все на это попадаемся
Этот миф настолько живуч, потому что практически вся наша система обратной связи построена на неподвижных кадрах.
Обсуждение дизайна происходит в Figma, которая по умолчанию статична. Портфолио состоят из скриншотов. Dribbble вознаграждает красивый кадр, а не хороший переход. Когда вы публикуете приложение и просите обратную связь, вы выкладываете PNG. Никто в комментариях не скажет вам, что переключение вкладки использует 600-миллисекундную кривую easeInOut, потому что никто приложение не трогал.
В результате мы становимся очень хороши в том, что проверяют, и остаёмся слепы к тому, что пользователь ощущает.
Люди в Apple, которые занимаются этой проблемой, очень прямо говорят о том, где находится настоящий сигнал качества.
Дело не только в частоте кадров. Важно то, что находится внутри этих кадров, — Чан Карунамуни, Apple Human Interface Team, Designing Fluid Interfaces, WWDC 2018
Частота кадров — это цифра. То, что происходит внутри кадров, — это вкус.
Что на самом деле проверяет пользователь
В первые несколько секунд пользователь бессознательно проводит один эксперимент:
Я что-то сделал. Этот объект это заметил?
Всё, что он затем думает о качестве приложения, вытекает из ответа на этот вопрос. Вот три признака, из-за которых приложение ощущается дешёвым, в том порядке, в котором пользователь их замечает.
Признак №1: прикосновение никак не подтверждается
Это самая распространённая проблема, и её легко пропустить, потому что код выглядит совершенно нормальным.
Неправильно. Кнопка с собственным фоном, но без ButtonStyle:
Button("Add to Cart") { addToCart() }
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(.rect(cornerRadius: 12))
Как только вы добавляете собственный фон, стандартная реакция SwiftUI на нажатие практически перестаёт быть полезной. Текст слегка тускнеет. Сама капсула остаётся полностью яркой и совершенно неподвижной. А палец пользователя при этом закрывает надпись. То есть с точки зрения пользователя кнопка вообще никак не реагирует.
Правильно — перенести визуальное оформление в ButtonStyle, чтобы получить доступ к isPressed:
struct PressableStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(.horizontal, 20)
.padding(.vertical, 14)
.foregroundStyle(.white)
.background(Color.accentColor, in: .rect(cornerRadius: 12))
.scaleEffect(configuration.isPressed ? 0.96 : 1)
.animation(
configuration.isPressed
? .easeOut(duration: 0.1)
: .snappy(duration: 0.35, extraBounce: 0.25),
value: configuration.isPressed
)
.sensoryFeedback(trigger: configuration.isPressed) { _, pressed in
pressed ? .impact(weight: .light) : nil
}
}
}
Обратите внимание на асимметрию анимации — в ней весь смысл. Нажатие занимает 100 мс и использует easeOut. Отпускание — это пружина длительностью 350 мс с небольшим отскоком.
Это не стилистическое решение. Это физика. Во время нажатия палец уже движется и прикладывает силу, поэтому кнопка должна поддаваться немедленно. Когда палец отпускают, сила исчезает, а объект возвращается под действием собственной упругости, поэтому он может немного перескочить конечное положение и затем стабилизироваться.
Используйте одну и ту же кривую для обоих направлений — и получите резиновый штамп.
Используйте две разные — и объект начнёт ощущаться так, будто у него есть масса.
Масштабируйте до 0.96, а не до 0.9. При 0.9 кнопка уже выглядит так, будто её собираются удалить.
Признак №2: все анимации используют одну кривую
Откройте проект и найдите все использования .easeInOut. Если их больше трёх — скорее всего, вот ваша проблема.
easeInOut начинает движение с нулевой скорости. Это правильно для чего-то, что приложение решило сделать самостоятельно. Но это неправильно для действий, вызванных пальцем, потому что палец не начинает движение с нулевой скорости. Анимация должна унаследовать уже существующий импульс.
Вот схема, которой я придерживаюсь — исчезновения должны быть быстрее появлений. Когда элемент появляется, пользователю нужно время, чтобы понять, что именно появилось.
Когда элемент исчезает, пользователь уже переключился на что-то другое, и длительная анимация превращается просто в налог на ожидание.
Признак №3: изменения состояний телепортируются
Дешёвые приложения просто переключаются между состояниями. Дорогие — переходят между ними.
Неправильно. Количество товаров в корзине мгновенно прыгает с 2 на 3:
Text("\(itemCount)")
.font(.headline.monospacedDigit())
// Right. One modifier, and the digit rolls:
Text("\(itemCount)")
.font(.headline.monospacedDigit())
.contentTransition(.numericText(value: Double(itemCount)))
// and the mutation has to be animated for it to fire
withAnimation(.snappy(duration: 0.3)) { itemCount += 1 }
Тот же принцип относится к состоянию загрузки, которое почти все делают неправильно. Если внутри if просто заменить Text на ProgressView, кнопка мгновенно поменяет ширину. Это воспринимается не как состояние загрузки, а как ошибка компоновки.
Оставьте размер кнопки неизменным, плавно перекрёстно замените содержимое — и кнопка будет ощущаться как один объект, который изменил своё состояние, а не как два разных объекта, подменивших друг друга.
Момент, который ломает интуицию почти всем
Когда разработчики соглашаются с тем, что проблема заключается в движении, они обычно делают следующий предсказуемый шаг: всё ускоряют.
Это неправильный инстинкт.
И теперь у нас есть хорошие данные, подтверждающие это.
Короткий факт: в трёх экспериментах с участием более 1400 человек анимации загрузки средней скорости — примерно 2000–2500 мс на один оборот — давали минимальное субъективное ощущение времени ожидания. Они оказались лучше медленных анимаций, статичных изображений, пустых экранов и быстрых анимаций. Ускорение вращения индикатора до 400–500 мс за оборот заставляло людей думать, что они ждали дольше, — Юй Дин, Stanford GSB, и Элли Кён, Babson, Journal of Consumer Research, 2025
Прочитайте ещё раз. Более быстрый индикатор заставлял людей думать, что они ждали дольше.
Дело не в скорости. Дело в читаемости движения. За бешено вращающимся индикатором сложно следить, поэтому внимание снова переключается на сам факт ожидания. Умеренная анимация даёт глазу связное движение, за которым можно следить, и незаметно поглощает время.
Именно поэтому стратегия «просто уменьшим длительность всех анимаций» не работает. Появление модального окна за 120 мс не ощущается премиально. Оно ощущается как резкая монтажная склейка. Цель никогда не состояла в скорости.
Цель — чтобы каждое движение воспринималось как единое, непрерывное и правдоподобное событие.
Ментальная модель, которая действительно помогает
Перестаньте относиться к анимации как к украшению, которое добавляется в конце. Смотрите на неё как на причинно-следственную историю, которую интерфейс рассказывает о самом себе. Почти всё сводится к трём правилам.
1. Каждый ввод должен получить ответ максимум за 100 мс
Не готовый результат. Просто подтверждение. Масштабирование. Подсветка. Тактильный отклик. Именно задержка первой убивает ощущение качества, и пользователи гораздо чувствительнее к ней, чем к любому оттенку вашей цветовой палитры.
2. Объекты должны сохранять непрерывность
Если объект существует и до, и после изменения состояния, он должен переместиться из одного состояния в другое. Он не должен исчезнуть и внезапно появиться где-то ещё. Именно для этого существуют matchedGeometryEffect и contentTransition.
3. Движение должно соответствовать силе, которая его вызвала
Движение, инициированное пользователем, наследует импульс пользователя — поэтому здесь подходят пружины. Движение, инициированное системой, начинается из состояния покоя — поэтому здесь вполне уместны сглаженные кривые. Не путайте эти два случая.
При этом в собственных рекомендациях Apple есть важное ограничение, которое стоит буквально повесить над рабочим столом:
Не добавляйте движение просто ради движения. Беспричинная или чрезмерная анимация может отвлекать людей или заставлять их чувствовать себя оторванными от происходящего, — Apple Human Interface Guidelines, Motion
Исправление дешёвого ощущения приложения почти никогда не означает «добавить больше анимации». Нужно примерно то же количество анимации — просто она должна честно подчиняться физике.
Подведём итог
- Ощущение дешевизны — это проблема поведения, а не внешнего вида. Скриншоты её скрывают, поэтому оно и переживает ваши дизайн-ревью.
- Перенесите состояние нажатия в
ButtonStyle, чтобы получить доступ кisPressed. Быстрое продавливание, затем возврат на пружине. - Перестаньте использовать
.easeInOutкак универсальный вариант. Выбирайте кривую в зависимости от силы, которая вызвала движение. - Исчезновение должно происходить быстрее появления. Числа и надписи должны переходить между состояниями, а не телепортироваться.
- Цель — не скорость, а связность. Данные Stanford показывают, что слишком быстрый индикатор загрузки действительно заставляет ожидание ощущаться дольше.
Сегодня вечером выберите в своём приложении кнопку, которую нажимают чаще всего, и примените к ней описанный выше ButtonStyle. Десять минут работы. Разницу вы почувствуете ещё до того, как закончите тестирование.
Какой из этих трёх признаков прямо сейчас скрывается в вашем приложении? Пишите в комментариях. А если хотите вторую часть про прерываемые переходы, управляемые жестами, — следите за продолжением.
-
Новости3 недели назадВидео и подкасты о мобильной разработке 2026.30
-
Видео и подкасты для разработчиков3 недели назадSpec-Driven Development на практике — SDD, BMAD, OpenSpec, вайб кодинг
-
Компании4 недели назадЗаморозили USDT: что делать и как вернуть доступ к деньгам
-
Обучение4 недели назадМенторство в IT: как опыт одного специалиста ускоряет развитие другого
