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

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

Скрытый текст

В этой статье я расскажу, как проект прошел путь от простого прототипа на 4 стороны света до динамической сетки на Jetpack Compose, с какими подводными камнями Android пришлось столкнуться и как устроена математика свайпов под капотом.

Откуда идея: Kando и PieLauncher

В октябре 2023 года я наткнулся на проект Kando — круговое меню для компьютера.

Скриншот с https://github.com/kando-menu/kando

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

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

MVP на 4 стороны света

Первый прототип был максимально прямолинейным:

  • Меню строго на 4 направления: Вверх, Вниз, Влево, Вправо.

  • Проверка попадания на элемент по фиксированным координатам областей.

  • Три типа действий: запуск приложения, открытие нового меню и открытие настроек лаунчера.

Скрытый текст

Лаунчер уже работал, но нужно было решить две проблемы:

  1. Как отображать системные обои без костылей

  2. Как запускать приложения, которые пользователь не добавил в меню

Битва за системные обои

Казалось бы, тривиальная задача. Моя первая наивная реализация выглядела так:

  1. Запросить READ_EXTERNAL_STORAGE / READ_MEDIA_IMAGES (чтение файлов устройства).

  2. Достать Drawable обоев через WallpaperManager.getInstance(context).drawable.

  3. Сконвертировать его в Bitmap и отрендерить через Compose Image(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 элементов, в круге оставались некрасивые пустоты. Меню должно быть динамическим, то есть должно уметь адаптироваться под любое количество элементов.

Как работает динамическое меню:

  1. Мертвая зона: пока палец сместился меньше чем на radius от точки касания, меню не реагирует (x² + y² < r²).

  2. Расчет сектора: окружность делится на N равных секторов (α = 360/N).

  3. Сдвиг: чтобы первый элемент стоял ровно сверху по центру, применяется смещение всех секторов на ‑α/2 (чтобы центр первого сектора был сверху).

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

  5. Выполняется действие элемента в выбранном секторе

Таким образом пользователь может создавать меню с любым количеством элементов. Старый и новый код представлены в разделе оптимизации подсчётов при свайпе.

Архитектурный тупик: ловушка многомодульности

В какой‑то момент проект начал активно обрастать фичами: кастомные иконки, экспорт/импорт, Room‑база данных, работа с фонариком, темы. И тут меня настигло желание сделать всё «по взрослому».

Я решил переписать проект на многомодульную архитектуру. Именно переписать, то есть я создал новый проект и начал реализовывать ту же функциональность деля всё на модули. Но я переборщил (получилось около 40 модулей).

Это привело к длительному застою в разработке:

  • Связывание зависимостей и сборка в Gradle отнимали уйму времени.

  • Постоянное разбиение на модули и продумывание архитектуры занимало кучу времени

  • Пропало главное преимущество пет‑проекта — скорость экспериментов (я не занимался реализацией новой функциональности, а просто пытался красиво переписать старую).

В итоге я вовремя остановился, убрал избыточную модульность и вернулся к понятному одномодульному проекту с разделением по слоям (Domain / Data / Presentation). Это сразу вернуло темп разработки.

Итогом всей этой работы стал простой вывод: не нужно менять всё и сразу. Лучше улучшать архитектуру постепенно, продумывая каждый шаг и при этом не останавливая разработку новой функциональности.

Непрерывный свайп и вложенные подменю

Главная фишка SwipeLauncher — возможность раскрывать вложенные меню одним непрерывным движением. Для обработки жестов я использовал pointerInteropFilter. Вот как устроена моя система считывания свайпов:

  1. ACTION_DOWN — фиксируем стартовую точку касания (центр меню). Если с момента прошлого клика прошло меньше 300 мс — считаем это двойным нажатием и открываем список приложений.

  2. ACTION_MOVE — вычисляем вектор смещения пальца относительно центра (точки касания из ACTION_DOWN). Как только палец выходит за радиус мёртвой зоны, через арктангенс определяю сектор и выполняю действие.

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

  4. 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)


  1. redtom
    27.08.2026 12:51

    Писать свой DI, как мне кажется, тут было лишнее. И ещё: когда вижу в своём любимом Kotlin

    return null

    Аж глаз начинает дёргаться.

    Это всего лишь моё мнение, не обязательно правильное. Опыт-то кайфовый и штука прикольная получилась.


    1. Knomster Автор
      27.08.2026 12:51

      Насчёт DI думаю, что ты прав. Я когда пытался проект на много модулей разделить Koin использовал. Но когда обратно к одному модулю вернулся, то решил всё постепенно внедрять, начал с создания UseCase при старте приложения. Подумал, что только для этого не нужно всю библиотеку как зависимость брать (ну и хотелось попробовать самому написать). А потом по необходимости добавил и ViewModel и фабрики.

      Про null я думаю, что лучше более информативно делать (стараюсь создавать sealed классы). И блок с получением индекса переписал, разделив на 2 части: одна проверяет, что касание за мёртвой зоной (ранее в этом случае возвращался null, теперь же это просто boolean), а затем идёт получение индекса по координатам.


      1. redtom
        27.08.2026 12:51

        Очень советую использовать null-safety в Kotlinяке по-максимуму: sealed-class для описания алгебраических типов, они же состояния системы и чистые функции, где `f(n) = y` всегда. Но это из разряда "делай хорошо, а плохо не делай". Поэтому отправляю прямиком к дедушке Влошину: https://fsharpforfunandprofit.com/. Да, это не Котлиняка, но уверен, ты там подчерпнёшь много идей. Твои exhaustive(почти) pattern-matching'и уже обнадёживают. Так держать!


        1. Knomster Автор
          27.08.2026 12:51

          Как раз начал изучать функциональное программирование. Спасибо за ресурс!


  1. Maxim9t9viwh
    27.08.2026 12:51

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


    1. Knomster Автор
      27.08.2026 12:51

      Нейронками в последнее время стал пользоваться чаще. С их помощью проще искать информацию и её источники. С агентным режимом удобно работать с проектами, но не в качестве писателей кода, а скорее тех, кто проверяет написанное и предлагает улучшения. Наверное, с текущим развитием ИИ они могут и фичи реализовывать, но пока что я им это не доверяю (нравиться мне процесс самостоятельного написания кода и ощущение понимания что и как написано). Про рынок сказать не могу, сам пока что учусь.