
Большинство лаунчеров для Android повторяют одну и ту же концепцию сетки иконок. Чтобы открыть приложение, нужно листать бесконечные рабочие столы и тянуться пальцем до нужной иконки (а она может быть и в верхнем углу экрана, поэтому часто приходится использовать вторую руку).
Я решил сделать SwipeLauncher — открытый лаунчер с круговыми меню, где любое действие или вложенное меню открывается одним свайпом из любой точки экрана.
Скрытый текст

В этой статье я расскажу, как проект прошел путь от простого прототипа на 4 стороны света до динамической сетки на Jetpack Compose, с какими подводными камнями Android пришлось столкнуться и как устроена математика свайпов под капотом.
Откуда идея: Kando и PieLauncher
В октябре 2023 года я наткнулся на проект Kando — круговое меню для компьютера.

Концепция выбора элементов по направлению движения запала мне в душу, но я почти не пользуюсь мышью. А вот на смартфоне, где палец всегда на экране, это идеальный способ взаимодействия.
Среди аналогов для Android я нашел PieLauncher. Он был хорош, но в нем не хватало ключевой фичи — вложенных подменю (возможности свайпнуть на элемент и раскрыть следующее меню без отрыва пальца). Поэтому я решил написать свой лаунчер на Jetpack Compose (так как в то время как раз занимался его изучением).
MVP на 4 стороны света
Первый прототип был максимально прямолинейным:
Меню строго на 4 направления: Вверх, Вниз, Влево, Вправо.
Проверка попадания на элемент по фиксированным координатам областей.
Три типа действий: запуск приложения, открытие нового меню и открытие настроек лаунчера.
Скрытый текст

Лаунчер уже работал, но нужно было решить две проблемы:
Как отображать системные обои без костылей
Как запускать приложения, которые пользователь не добавил в меню
Битва за системные обои
Казалось бы, тривиальная задача. Моя первая наивная реализация выглядела так:
Запросить
READ_EXTERNAL_STORAGE/READ_MEDIA_IMAGES(чтение файлов устройства).Достать Drawable обоев через
WallpaperManager.getInstance(context).drawable.Сконвертировать его в
Bitmapи отрендерить через ComposeImage(contentScale = ContentScale.Crop).
Это решение собрало все возможные минусы:
Запрос разрешений пугал пользователя при первом запуске.
На Android 13+ из‑за ограничений Scoped Storage метод работал нестабильно.
Живые обои (Live Wallpapers) превращались в статичную картинку.
Лишний
Bitmapна весь экран забивал память.
Решение оказалось в разы проще и чище — отдать отрисовку обоев системе на уровне WindowManager. Достаточно унаследовать тему Activity от системной темы Wallpaper:
<style name="Theme.SwipeLauncher" parent="android:Theme.Material.Wallpaper.NoTitleBar" />
Окно становится прозрачным, Compose отрисовывает элементы поверх, а живые и статические обои рендерятся самой ОС без единого runtime‑разрешения.
Список всех приложений и защита от мисскликов
Лаунчер должен предоставлять доступ ко всем приложениям, а не только к тем, что есть в меню. Для этого нужно было реализовать список всех приложений, а также подумать, как его открыть с основного экрана. Я вдохновился способом PieLauncher: двойное касание в любом месте экрана открывает поиск и список всех приложений.
Скрытый текст

Параллельно добавились действия звонков:
Call — мгновенный вызов по номеру. Требует опасного разрешения CALL_PHONE, а при быстром свайпе легко промахнуться и случайно совершить вызов.
Dial — открытие системной звонилки с заданным номером. Разрешений не требует, а звонок начнется только после нажатия кнопки вызова пользователем.
От 4 кнопок к динамической геометрии (N секторов)
Фиксированные 4 (а позже 8) кнопок создавали визуальную проблему: если пользователю нужно всего 3 или 5 элементов, в круге оставались некрасивые пустоты. Меню должно быть динамическим, то есть должно уметь адаптироваться под любое количество элементов.
Как работает динамическое меню:
Мертвая зона: пока палец сместился меньше чем на radius от точки касания, меню не реагирует (x² + y² < r²).
Расчет сектора: окружность делится на N равных секторов (α = 360/N).
Сдвиг: чтобы первый элемент стоял ровно сверху по центру, применяется смещение всех секторов на ‑α/2 (чтобы центр первого сектора был сверху).
Определение направления: по координатам свайпа через арктангенс вычисляется угол. По углу определяется сектор.
Выполняется действие элемента в выбранном секторе
Таким образом пользователь может создавать меню с любым количеством элементов. Старый и новый код представлены в разделе оптимизации подсчётов при свайпе.
Архитектурный тупик: ловушка многомодульности
В какой‑то момент проект начал активно обрастать фичами: кастомные иконки, экспорт/импорт, Room‑база данных, работа с фонариком, темы. И тут меня настигло желание сделать всё «по взрослому».
Я решил переписать проект на многомодульную архитектуру. Именно переписать, то есть я создал новый проект и начал реализовывать ту же функциональность деля всё на модули. Но я переборщил (получилось около 40 модулей).
Это привело к длительному застою в разработке:
Связывание зависимостей и сборка в Gradle отнимали уйму времени.
Постоянное разбиение на модули и продумывание архитектуры занимало кучу времени
Пропало главное преимущество пет‑проекта — скорость экспериментов (я не занимался реализацией новой функциональности, а просто пытался красиво переписать старую).
В итоге я вовремя остановился, убрал избыточную модульность и вернулся к понятному одномодульному проекту с разделением по слоям (Domain / Data / Presentation). Это сразу вернуло темп разработки.
Итогом всей этой работы стал простой вывод: не нужно менять всё и сразу. Лучше улучшать архитектуру постепенно, продумывая каждый шаг и при этом не останавливая разработку новой функциональности.
Непрерывный свайп и вложенные подменю
Главная фишка SwipeLauncher — возможность раскрывать вложенные меню одним непрерывным движением. Для обработки жестов я использовал pointerInteropFilter. Вот как устроена моя система считывания свайпов:
ACTION_DOWN — фиксируем стартовую точку касания (центр меню). Если с момента прошлого клика прошло меньше 300 мс — считаем это двойным нажатием и открываем список приложений.
ACTION_MOVE — вычисляем вектор смещения пальца относительно центра (точки касания из ACTION_DOWN). Как только палец выходит за радиус мёртвой зоны, через арктангенс определяю сектор и выполняю действие.
Как работает открытие нового меню: если выбранный элемент имеет действие — OpenCircleMenu, то центр меню мгновенно перемещается в текущие координаты пальца, срабатывает легкий виброотклик, и меню переключается на заданное. Пользователь может, не отрывая пальца, сразу продолжить движение в сторону нужного элемента.
ACTION_UP — сброс состояния на стартовое меню и закрытие меню.
fun onSwipe(): (MotionEvent) -> Boolean = { event -> val offset = Offset( x = event.x / density, y = event.y / density ) when (event.action) { MotionEvent.ACTION_DOWN -> { if (event.eventTime - clickTime < 300L) { // Двойное нажатие _searchText.value = TextFieldValue("") _screenState.value = LauncherScreenState.SearchBox } else { currentMenuOffset.value = offset } clickTime = event.eventTime } MotionEvent.ACTION_MOVE -> { currentMenuOffset.value?.let { menuOffset -> val swipeOffset = Offset( x = offset.x - menuOffset.x, y = offset.y - menuOffset.y, ) if (cordsOutRadius(swipeOffset)) { // Если вышли за мёртвую зону val index = itemIndexOnCords.value(swipeOffset) currentMenu.value?.items?.getOrNull(index)?.let { item -> viewModelScope.launch { executeAction(item.action, offset) } } } } } MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { currentMenuId.value = 0 currentMenuOffset.value = null } } true }
Оптимизация подсчётов при свайпе
На современных смартфонах тач‑события генерируются постоянно. В первой версии я рассчитывал списки углов прямо внутри обработчика свайпа:
private fun getElementIndexOnCords( offset: Offset ): Int? { currentMenu.value?.circleMenu?.let { circleMenu -> if (offset.x.pow(2) + offset.y.pow(2) > radiusSq) { val alpha = 360f / circleMenu.items.size val angles = (0 until circleMenu.items.size).map { alpha * it } val currentAngle = if (offset.y == 0f) { ((if (offset.x > 0) 90 else 270) - circleMenu.getStartOffset()) % 360 } else { offset.getAngle( abs((atan(offset.x / offset.y) / PI * 180)).toFloat(), circleMenu.getStartOffset() ) } angles.forEachIndexed { index, it -> if (it > currentAngle) { return index - 1 } } return circleMenu.items.size - 1 } } return null } private fun Offset.getAngle(angle: Float, startOffset: Float): Float { return (if (x > 0) { if (y < 0) { angle - startOffset } else { 90 - angle + 90 - startOffset } } else { if (y < 0) { 90 - angle + 270 - startOffset } else { angle + 180 - startOffset } }) % 360 } private fun CircleMenu.getStartOffset(): Float { if (items.isEmpty()) { return 0f } return -360 / items.size / 2f }
Это создавало мусор в памяти, а также замедляло работу лаунчера.
Решение: я вынес генерацию математических функций в отдельный UseCase с кешированием. Для каждого количества элементов N единожды генерируется готовая лямбда
private val generators = mutableMapOf<ItemsCount, (Offset) -> Int>() // Вот эта функция возвращает генератор индекса по offset fun getItemIndexOnCordsGenerator(itemsCount: ItemsCount): (Offset) -> Int { return generators.getOrElse(itemsCount) { val newGenerator = itemIndexOnCordsGenerator(getAngles(itemsCount)) generators[itemsCount] = newGenerator newGenerator } } fun getAngles(itemsCount: ItemsCount): List<Float> = if (itemsCount == 0) { emptyList() } else { (0 until itemsCount).map { 360f / itemsCount * (it + 0.5f) } } fun itemIndexOnCordsGenerator(angles: List<Float>): (Offset) -> Int { if (angles.isEmpty()) { return { 0 } } return { cords -> // После генерации вычисления происходят только здесь angles.getIndex( currentAngle = cords.getAngle() ) } } private fun List<Float>.getIndex(currentAngle: Float): Int { forEachIndexed { index, angle -> if (currentAngle < angle) return index } return 0 } private fun Offset.getAngle(): Float { if (y == 0f) { return if (x > 0f) 90f else 270f } val angle = (atan(x / y) / PI * 180f).toFloat() return if (y > 0) { 180 - angle } else { if (angle > 0) { 360 - angle } else { -angle } } }
Собственный DI вместо Hilt/Koin
После отказа от 40 модулей и возврата к монолиту встал вопрос управления зависимостями. В проекте уже было приличное количество UseCase, репозиториев и ViewModel.
Стандартные решения — это Hilt или Koin, вот почему я отказался от них:
Hilt — слишком тяжеловесный. Он генерирует код при компиляции, а это замедляет сборку и заставляет расставлять десятки аннотаций (что не очень удобно, по моему мнению).
Koin — удобнее, но реализует слишком много всего, что мне не нужно (а это увеличивает размер приложения, что критично для лаунчера).
Мне хотелось иметь максимальный контроль, мгновенную компиляцию и малый прирост размера APK. Поэтому я написал собственный DIContainer всего на ~100 строк кода, который позволял работать так же удобно как и с Koin, но при этом реализовывал только то, что мне нужно.
Как устроен мой DI:
В основе — потокобезопасный ConcurrentHashMap с парой (String, KClass) в качестве ключа и generic‑функции с reified типами:
typealias DIKey = String class DIContainer { @PublishedApi internal val defaultKey = null @PublishedApi internal val singles = ConcurrentHashMap<Pair<DIKey?, KClass<*>>, Any>() @PublishedApi internal val singleFactories = ConcurrentHashMap<Pair<DIKey?, KClass<*>>, () -> Any>() inline fun <reified T: Any> insertSingle(noinline factory: () -> T) { singleFactories[getDefaultKey(T::class)] = factory } inline fun <reified T: Any> insertSingle(key: DIKey, noinline factory: () -> T) { singleFactories[getKey(key, T::class)] = factory } inline fun <reified T: Any> getSingle(): T { val key = getDefaultKey(T::class) if (singles.containsKey(key)) return singles[key] as T synchronized(this) { val factory = singleFactories[key] as? () -> T ?: throw IllegalStateException("Not found single for ${T::class}") val dependency = factory() singles[key] = dependency return dependency } } inline fun <reified T: Any> getSingle( key: DIKey ): T { val key = getKey(key, T::class) if (singles.containsKey(key)) return singles[key] as T synchronized(this) { val factory = singleFactories[key] as? () -> T ?: throw IllegalStateException("Not found single for ${T::class}") val dependency = factory() singles[key] = dependency return dependency } } @PublishedApi internal fun getDefaultKey(kclass: KClass<*>): Pair<DIKey?, KClass<*>> = defaultKey to kclass @PublishedApi internal fun getKey(key: DIKey, kclass: KClass<*>): Pair<DIKey, KClass<*>> = key to kclass }
Во время реализации DI я столкнулся с проблемой: хотелось сделать получение зависимости через inline функцию (чтобы можно было узнавать тип зависимости), но тогда в этой функции нужно было обращаться к открытой ConcurrentHashMap (так как это inline функция). Но делать хранилище зависимостей открытым я не хотел.
При поиске решения я наткнулся на @PublishedApi — аннотация компилятора, которая позволяет сделать internal поле открытым для компиляции, а вот при разработке оно считалось всё также internal. Это как раз решило проблему: я вынес DIContainer в отдельный модуль и закрыл хранилище зависимостей с помощью @PublishedApi.
Далее я аналогично реализовал работу с фабриками:
@PublishedApi internal val factories = ConcurrentHashMap<Pair<DIKey?, KClass<*>>, () -> Any>() inline fun <reified T: Any> insertFactory(noinline factory: () -> T) { factories[getDefaultKey(T::class)] = factory } inline fun <reified T: Any> insertFactory(key: DIKey, noinline factory: () -> T) { factories[getKey(key, T::class)] = factory } @Suppress("UNCHECKED_CAST") inline fun <reified T: Any> getFactory(): T { val key = getDefaultKey(T::class) val factory = factories[key] as? () -> T ?: throw IllegalStateException("Not found factory for ${T::class}") return factory() } @Suppress("UNCHECKED_CAST") inline fun <reified T: Any> getFactory(key: DIKey): T { val key = getKey(key, T::class) val factory = factories[key] as? () -> T ?: throw IllegalStateException("Not found factory for ${T::class}") return factory() }
А ещё сделал удобную работу с viewModel:
Вот эта часть в DIContainer
@PublishedApi internal val viewModelCreators = ConcurrentHashMap<KClass<out ViewModel>, (GetParameters) -> ViewModel>() inline fun <reified T: ViewModel> registerViewModel(noinline creator: (GetParameters) -> T) { viewModelCreators[T::class] = creator } inline fun <reified T: ViewModel> getViewModelCreator(): (GetParameters) -> T { @Suppress("UNCHECKED_CAST") return (viewModelCreators[T::class] as? (GetParameters) -> T) ?: throw IllegalStateException("Not found creator for ${T::class}") }
А это функция для получения viewModel из кода
@Composable inline fun <reified T: ViewModel> diViewModel(insertParameters: (InsertParameters) -> Unit = {}): T { val diParameters = DIParameters() insertParameters(diParameters) val creator = DI.getViewModelCreator<T>() val factory = viewModelFactory { addInitializer(T::class) { creator(diParameters) } } return viewModel(factory = factory) }
Ну и DIParameters для передачи параметров в viewModel при создании
class DIParameters: InsertParameters, GetParameters { private val parameters = mutableMapOf<Any?, Any?>() override fun insert(key: Any?, value: Any?) { parameters[key] = value } override fun <T> get(key: Any?): T { @Suppress("UNCHECKED_CAST") return parameters[key] as T } }
Код моего DI вы также можете посмотреть и на GitHub: https://github.com/kindeev63/SwipeLauncher/tree/master/di
Эволюция UI: интерактивный редактор кругов и экраны настроек
Лаунчер — это не только главный экран со свайпами, но и удобный инструмент настройки. Если человеку сложно создать меню под себя, приложение долго не проживёт на устройстве.
1. Интерактивный редактор меню (EditCircleMenuScreen)
Я сделал интерактивный визуальный редактор:
Пользователь видит точную копию меню прямо на экране.
Можно нажать на любой сектор, чтобы переназначить действие или изменить картинку.
Можно перетаскивать элементы, добавлять новые и удалять ненужные с помощью свайпов.
Скрытый текст

2. Универсальные диалоги выбора (ImageDialog и ActionDialog)
Для каждого элемента можно настроить любую комбинацию картинки и действия:
-
Типы изображений:
Иконка приложения
Изображение из коллекции
Пользовательское изображение
-
Типы действий:
Открыть приложение
Открыть меню
Открыть ссылку
Позвонить
Набрать номер
Включить фонарик
Выключить фонарик
Изменить состояние фонарика (вкл/выкл)
Я вынес выбор в модульные диалоги с вкладками и быстрым поиском:
Типы действий

Типы изображений

3. Планшеты и онбординг
На экранах планшетов стандартный фиксированный радиус меню выглядел гигантским. Я переработал геометрию: базовый размер меню теперь рассчитывается от наименьшей стороны экрана (minScreenLength / 3 * 2), а интерфейс настроек на больших экранах адаптируется в двухколоночный режим:

А чтобы новые пользователи не терялись при первом знакомстве с непривычным жестовым интерфейсом, я добавил обучение, которое открывается при старте приложения:
Онбординг

Итоги и планы на будущее
Создание SwipeLauncher началось как эксперимент по изучению Jetpack Compose, а в итоге превратилось в мой основной лаунчер, которым я с удовольствием пользуюсь каждый день.
Чего я добился:
Крошечный размер: APK весит всего ~10-15 МБ (никаких тяжелых зависимостей).
Мгновенная отзывчивость: генераторы позволяют вычислять значение мгновенно (нет никаких фризов)
Удобство использования: я создавал этот лаунчер как удобный домашний экран для себя, так что могу смело сказать, что после некоторого времени на выработку мышечной памяти вы будете работать со смартфоном быстрее
Расширяемость: проект легко расширять благодаря чистой архитектуре и DI.
Что в планах:
Добавить еще больше действий и изображений в стандартную коллекцию.
Улучшать архитектуру проекта
Проект полностью открыт под лицензией Apache 2.0. Буду рад вашим звездочкам на GitHub, отзывам и предложениям:
? Репозиторий SwipeLauncher на GitHub
? Канал лаунчера в Telegram
Спасибо за чтение! Буду рад ответить на любые вопросы в комментариях.
Комментарии (8)

Maxim9t9viwh
27.08.2026 12:51Обалдеть, насколько может же может быть андроид разработка разносторонней, функциональной и удобной, в моём представлении это было совсем по другому, я только начинаю вливаться в процесс, так что, есть чему удивляться, а вопрос, насколько часто ты пользуешься нейронками? И как обстоят дела на рынке с участием их использования?

Knomster Автор
27.08.2026 12:51Нейронками в последнее время стал пользоваться чаще. С их помощью проще искать информацию и её источники. С агентным режимом удобно работать с проектами, но не в качестве писателей кода, а скорее тех, кто проверяет написанное и предлагает улучшения. Наверное, с текущим развитием ИИ они могут и фичи реализовывать, но пока что я им это не доверяю (нравиться мне процесс самостоятельного написания кода и ощущение понимания что и как написано). Про рынок сказать не могу, сам пока что учусь.
redtom
Писать свой
DI, как мне кажется, тут было лишнее. И ещё: когда вижу в своём любимомKotlinАж глаз начинает дёргаться.
Это всего лишь моё мнение, не обязательно правильное. Опыт-то кайфовый и штука прикольная получилась.
Knomster Автор
Насчёт DI думаю, что ты прав. Я когда пытался проект на много модулей разделить Koin использовал. Но когда обратно к одному модулю вернулся, то решил всё постепенно внедрять, начал с создания UseCase при старте приложения. Подумал, что только для этого не нужно всю библиотеку как зависимость брать (ну и хотелось попробовать самому написать). А потом по необходимости добавил и ViewModel и фабрики.
Про null я думаю, что лучше более информативно делать (стараюсь создавать sealed классы). И блок с получением индекса переписал, разделив на 2 части: одна проверяет, что касание за мёртвой зоной (ранее в этом случае возвращался null, теперь же это просто boolean), а затем идёт получение индекса по координатам.
redtom
Очень советую использовать
null-safetyвKotlinяке по-максимуму: sealed-class для описания алгебраических типов, они же состояния системы и чистые функции, где `f(n) = y` всегда. Но это из разряда "делай хорошо, а плохо не делай". Поэтому отправляю прямиком к дедушке Влошину: https://fsharpforfunandprofit.com/. Да, это не Котлиняка, но уверен, ты там подчерпнёшь много идей. Твои exhaustive(почти) pattern-matching'и уже обнадёживают. Так держать!Knomster Автор
Как раз начал изучать функциональное программирование. Спасибо за ресурс!