One UI своими руками в домашних условиях

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

One UI существует уже как 3 года (с 2018), а уроков по тому, как сделать похожий дизайн, в мире android, я так и не нашёл. Не порядок… Сегодня же, мы начнём прокладывать этот тяжёлый и тернистый путь.

Зарождение Siesta

Я думаю, разделить статьи на версии нашего домашнего One UI. Благодаря этому, я буду иметь уникальную возможность общаться и отвечать на интересующие вас вопросы. Возможно, вы знаете более оптимизированный и лучший способ реализовать что-либо и в следующей статье (версии нашего One UI - Siesta) мы можем это применить.

В результате всех наших страданий мы получим вот такое приложение на выходе. Да, это не копия Samsung оболочки (прям совсем), но наша цель – унаследовать лишь идею использования одной рукой, а не скопировать шрифты и иконки…

Идея One UI

Давайте взглянем на стандартное приложение настроек в OneUI.

Весь экран можно разделить на зоны, где 1/3 экрана занимает огромный текст, показывающий на какой вкладке настроек мы находимся. Благодаря ему пользователи не должны тянуться на верхние края экрана. Затем идёт 2-ая зона, назовём её “панель управления”, эта зона прокручивается пока не достигнет верхнего края экрана, после чего будет примагничена, дабы не улететь за его края. В последнюю зону входит весь остальной контент, с которым будет взаимодействовать пользователь.

Это и есть главная идея OneUI которую мы должны повторить.

Создание и настройка проекта

Начнём с создания нового проекта, укажем минимальные требования Android 8.0, так как начиная с данной версии в TextView можно присвоить AutoSize параметр. Если вам необходимо работать с более ранними версиями Android – не беда. Существует библиотека поддержки в таких случаях.

Распространение Android
Информация взята из Android Studio
Информация взята из Android Studio
https://www.appbrain.com/stats/top-android-sdk-versions
https://www.appbrain.com/stats/top-android-sdk-versions

Frontend - создание зон

Итак, перед нами пустое Activity. Родительским элементом будет являться RelativeLayout, потому что нам необходимо поставить “панель управления” на ScrollView. После чего создаём сам ScrollView. В ScrollView может находится лишь один дочерний элемент, этим элементом будет являться LinearLayout, т.к. он позволяет распределять элементы последовательно. В примере я вставил часть кода, чтобы вы понимали наглядно о чём идёт речь.

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

<!--место 2 зоны-->
    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="50dp">

    </RelativeLayout>

<!--здесь будут храниться 1 и 3 зона-->
    <ScrollView
        android:id="@+id/scrollView"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <LinearLayout
            android:id="@+id/scrollLinearLayout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

        </LinearLayout>
    </ScrollView>
</RelativeLayout>

1 зона - theBigText

Теперь необходимо создать тот самый большой текст. Дадим ему id, строгую высоту в 250dp, установим параметр autoSizeTextType=”uniform” для автоматического изменения размера, gravity=”center” для центрирования, пару padding’ов для красоты и жирный шрифт.

<TextView
    android:id="@+id/theBigText"
    android:layout_width="match_parent"
    android:layout_height="250dp"
    android:autoSizeTextType="uniform"
    android:gravity="center"
    android:paddingHorizontal="50dp"
    android:paddingTop="100dp"
    android:paddingBottom="30dp"
    android:text="Настройки"
    android:textStyle="bold"/>

2 зона - "панель управления"

Теперь изменим нашу панель управления.

Добавим id, установим строгую высоту в 50dp, orientation=”horizontal” для правильного отображения элементов и layout_marginTop=”250dp” (в размер нашего главного текста, чтобы быть ровно под ним). Мы не можем установить атрибуты, обращающиеся напрямую к theBigText, т.к. он является дочерним элементом ScrollView, поэтому приходится ставить строгие значения. Заполним нашу “панель управления”. Вставим в неё TextView и установим для неё атрибуты: gravity=”center_vertical”, textSize=”30sp” и alpha=”0” (ведь текст должен быть виден только, когда панель прокручена вверх)

ImageView: установим строгий размер в 40dp, gravity=”center_vertical”, alpha=”0.7”, установим картинку компаса и присоединим к правой части экрана.

Опять же, все наглядные примеры находятся ниже.

<RelativeLayout
    android:id="@+id/panel"
    android:layout_width="match_parent"
    android:layout_height="50dp"
    android:layout_marginTop="250dp"
    android:background="@color/white"
    android:orientation="horizontal">

    <TextView
        android:id="@+id/panelText"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:gravity="center_vertical"
        android:paddingStart="20dp"
        android:text="настройки"
        android:textSize="30sp"
        android:alpha="0" />

    <ImageView
        android:id="@+id/compassIcon"
        android:layout_width="40dp"
        android:layout_height="match_parent"
        android:layout_alignParentEnd="true"
        android:layout_marginEnd="20dp"
        android:gravity="center_vertical"
        android:src="@drawable/compas"
        android:alpha="0.7" />
</RelativeLayout>

Теперь мы столкнулись с проблемой. Наша “панель инструментов” занимает место, а если мы добавим любой элемент в ScrollView, то они будут пересекаться, поэтому, мы добавим пустой View, который будет съедать нужное нам под 2-ую зону место.

<View
    android:layout_width="match_parent"
    android:layout_height="80dp" />

3 зона - контент

Фронтенд главного Activity завершён. Теперь, давайте перейдём к заполнению 3-й зоны. Зоны контента. На 1-м скриншоте, можно было заметить, что зона контента состоит из “скруглённых прямоугольников”, давайте их повторим.

Для этого создадим отдельный LayoutResource под именем “block”. Он не будет иметь бэкенд, лишь xml файл.

CardView подойдёт под наш блок как никто лучше! Дадим нужные параметры CardView и заполним его картинкой и текстом. Все параметры схожи с предыдущими, поэтому повторно объяснять их не вижу смысла.

<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView android:id="@+id/cardView"
    android:layout_width="match_parent"
    android:layout_height="80dp"
    android:layout_margin="8dp"
    app:cardCornerRadius="20dp"
    app:cardElevation="20dp"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:android="http://schemas.android.com/apk/res/android">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="horizontal"
        android:weightSum="3">

        <ImageView
            android:id="@+id/image"
            android:layout_width="60dp"
            android:layout_height="60dp"
            android:layout_gravity="center"
            android:layout_marginVertical="20dp"
            android:layout_marginStart="10dp"
            android:alpha="0.7" />

        <TextView
            android:id="@+id/text"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_margin="10dp"
            android:textSize="22sp"
            android:textStyle="bold"
            android:alpha="0.7"
            android:gravity="left|center_vertical" />
    </LinearLayout>
</androidx.cardview.widget.CardView>

С фронтендом покончено, теперь можно переходить к самому интересному!

Backend

Перед setContentView я предлагаю внедрить несколько параметров. Все комментарии переносятся в код.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        supportActionBar?.hide()//Убирает верхний бар с названием приложения
        AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)//Ставит светлую тему по умолчанию
        window.navigationBarColor = resources.getColor(R.color.black)//Ставит чёрный цвет навигационной панели
        setContentView(R.layout.activity_main)
    }
}

Теперь же, нужно создать пару глобальных переменных

private lateinit var scrollView: ScrollView
private lateinit var panel: RelativeLayout
private lateinit var panelText: TextView
private lateinit var theBigText: TextView
private lateinit var compassIcon: ImageView
private lateinit var scrollLinearLayout: LinearLayout

После setContentView инициализируем наши переменные

scrollView = findViewById(R.id.scrollView)
panel = findViewById(R.id.panel)
panelText = findViewById(R.id.theBigText)
theBigText = findViewById(R.id.panelText)
compassIcon = findViewById(R.id.compassIcon)
scrollLinearLayout = findViewById(R.id.scrollLinearLayout)

Теперь, мы должны получить MarginTop нашей “панели управления” в пикселях, т.к. отслеживать теперь мы будем только их. Чтобы это сделать добавляем в глобальные переменные maxScroll.

private var maxScroll = 0
private var scrollY = 0

И находим сам отступ

val params =
    panel.layoutParams as ViewGroup.MarginLayoutParams//Высчитывает максимально возможный скролл для огромного текста
maxScroll = params.topMargin

Давайте заполним 3 зону контентом.

Для этого создадим функцию addCardToScroll. Все комментарии переехали в код, если вдруг что-то непонятно, отвечу в комментариях.

private fun addCardToScroll(_input: String) {
    val blockView = View.inflate(this, R.layout.block, null)//Создаём 1 block
    val blockText = blockView.findViewById<TextView>(R.id.text)//Инициализируем поле Text
    val blockImage = blockView.findViewById<ImageView>(R.id.image)//Инициализируем поле Image
    var isCheck = false
    blockText.text = _input
    blockImage.setImageResource(R.color.white)

    scrollLinearLayout.addView(blockView)//Добавляем block в scrollView

    val params =
        blockView.layoutParams as? ViewGroup.MarginLayoutParams
    params?.setMargins(20, 12, 20, 12)//Устанавливаем Margin
    blockView.layoutParams = params//Присваиваем новые параметры
    blockView.elevation = 20f//Поднимаем карточку вверх для появления тени вокруг
    blockText.setAutoSizeTextTypeUniformWithConfiguration(
        1, 22, 1, TypedValue.COMPLEX_UNIT_DIP)//С помощью кода устанавливаем атрибут AutoSize
    //Присваиваем слушатель
    blockView.setOnClickListener {
        isCheck = !isCheck
        if (isCheck)
            blockImage.setImageResource(R.drawable.checkyes)//Заменяем иконку
        else
            blockImage.setImageResource(R.drawable.checkno)

        animateView(blockImage)//Анимируем иконку
    }
}
//Анимация иконок
private fun animateView(view: ImageView) {
    when (val drawable = view.drawable) {
        is AnimatedVectorDrawable -> {
            drawable.start()
        }
    }
}

Мои картинки – анимации xml. Это позволяет добавить жизни в наше приложение. Вы же, можете заменить их на любую статичную картинку. Весь мой код, включая анимационные картинки, вы сможете найти на GitHub.

Уже хочется проверить, как работает наше приложение, не правда ли?

Для этого добавим в конце нашего onCreate() такие строчки, чтобы заполнить 3 зону контентом и запустим наше приложение.

for(index in 0..10){
    addCardToScroll(index.toString())
}

Оживляем "панель управления"

Что же, приложение работает, анимации – тоже. Хорошо. А вот наша “панель управления” стоит на своём и никуда не двигается – логично, мы ведь и не прописали в каких случаях она должна двигаться. Но сначала, я бы изменил цвета нашего статус бара. Для этого, необходимо зайти в value->colors и изменить цвета. Можете использовать мой “элегантный” набор.

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="purple_200">#000000</color>
    <color name="purple_500">#4E4E4E</color>
    <color name="purple_700">#505050</color>
    <color name="teal_200">#717171</color>
    <color name="teal_700">#515151</color>
    <color name="black">#FF000000</color>
    <color name="white">#FFFFFFFF</color>
</resources>

Теперь перейдём к “панели управления“.

Наша цель -> узнать когда был произведён скролл. Повесить своего рода слушатель на ScrollView. Это делается благодаря этим строчкам.

scrollView.viewTreeObserver.addOnScrollChangedListener(ViewTreeObserver.OnScrollChangedListener { //Включается когда производится скролл
    scrollEngine()
})

scrollEngine() – функция двигающая нашу “панель инструментов”, давайте напишем её.

private fun scrollEngine() {
    val upDown: Boolean = scrollView.scrollY < scrollY// true if scroll up
    val params =
        panel.layoutParams as ViewGroup.MarginLayoutParams//Параметры LinearLayout MiniMain

    val temp: Int = if (upDown) {//Считаем на сколько нужно подвинуть нашу panel
        if (scrollView.scrollY <= maxScroll) {
            params.topMargin + (scrollY - scrollView.scrollY)
        } else
            0
    } else
        params.topMargin - scrollView.scrollY + scrollY
    if ((temp < 0) && !(upDown)) {//Двигаем panel в зависимости от прокрутки
        params.topMargin = 0
    } else if ((temp > maxScroll) && (upDown)) {
        params.topMargin = maxScroll
    } else {
        params.topMargin = temp
    }

    panel.layoutParams = params//Присваиваем изменения
    scrollY = scrollView.scrollY
}

Выглядит страшно, но если посидеть 5 минут и разобраться как это работает, то всё сразу станет ясно. Исчерпывающие комментарии находятся непосредственно в коде.

Давайте запустим и проверим результат.

Как мы видим, всё работает прекрасно, не учитывая пару багов*. Ползунок, показывающий где мы находимся в ScrollView, пересекается с нашей “панелью инструментов”. И самым простым решением будет отключить его насовсем. Делается это в xml файле таким параметром в ScrollView.

android:scrollbars="none"

2-й проблемой является то, что наша панель периодически сливается с зоной контента. Эту проблему можно решить динамическим добавлением тенью.

private fun alphaElevation() {
        val params =
            panel.layoutParams as ViewGroup.MarginLayoutParams
        panelText.alpha =
            (1 - (scrollY.toFloat() * 100.0 / maxScroll.toFloat() / 100.0) / 0.5).toFloat()//Плавное исчезновение/появление panelText
        theBigText.alpha = (scrollY.toFloat() * 100.0 / maxScroll.toFloat() / 100.0).toFloat()//Плавное исчезновение/появление большого текста
//Если вдруг захотите, чтобы иконка тоже появлялась плавно, раскомментируете данный участок
//        var settingsAlpha =
//            ((scrollY.toFloat() * 100.0 / maxScroll.toFloat() / 100.0)).toFloat()
//        if (settingsAlpha < 0.7f)
//            CompasIcon.alpha = settingsAlpha
//        else
//            CompasIcon.alpha = 0.7f
        //Если panel достигла верхнего края экрана -> добавить тень
        if (params.topMargin == 0)
            panel.elevation = 10f
        else
            panel.elevation = 0.1f
    }

Теперь наш слушатель ScrollView должен выглядеть так.

scrollView.viewTreeObserver.addOnScrollChangedListener(ViewTreeObserver.OnScrollChangedListener { //Включается когда производится скролл
    scrollEngine()
    alphaElevation()
})

И снова проверим результат

Магия магнитов

Потрясающе, но есть одно но. “Панель инструментов” имеет 2 стандартных положения:

  1. Когда panel примагничено к верхнему краю экрана.

  2. Изначальное положение.

Так вот, в настоящем OneUI ScrollView смещается, если “панель инструментов” находится между этими положениями. И смещается оно в ту сторону, к которой ближе находится. Звучит возможно не понятно, но на следующей gif анимации всё будет показано.

Дополнительная проблема в том, что нам недостаточно отслеживать, где находится “панель инструментов”, нам необходимо знать, когда пользователь перестал взаимодействовать с экраном. Проще говоря - поднял палец. И для плавности работы и более лучшего внешнего вида, нам придётся применить задержку в какое-то количество секунд, ведь ScrollView может быть ещё в движении.

Начнём решать проблему с самых низов.

Необходимо узнать, когда пользователь прикасался к экрану, а когда нет. В этом нам поможет специальный слушатель.

//Проверка нажатия на экран
@SuppressLint("ClickableViewAccessibility")
fun touchFun() {
    scrollView.setOnTouchListener { v, event ->
        val action = event.action
        when (action) {
            MotionEvent.ACTION_DOWN -> {
                false
            }

            MotionEvent.ACTION_UP -> {
                threadTimer()
                false
            }
            MotionEvent.ACTION_CANCEL -> {
                false
            }
            MotionEvent.ACTION_OUTSIDE -> {
                false
            }
            else -> false
        }
    }
}

Проверка на нажатие есть. Теперь необходимо создать функцию, которая будет плавно примагничивать и двигать наш ScrollView.

//Таймер+приведение panel в стандартное положение
private fun threadTimer() {
    var lastScrollY = 0
    //Мы не имеем права использовать заморозки в главном UI потоке, поэтому вынуждены создать новый
    Thread {
        //Пока новое положение ScrollView не сравнится со старым
        while (scrollView.scaleY.toInt() != lastScrollY) {
            lastScrollY = scrollView.scrollY
            //Пол секунды
            Thread.sleep(500)
            //Если текущее положение не равняется предыдущему
            if (scrollView.scaleY.toInt() != lastScrollY)
                lastScrollY = scrollView.scaleY.toInt()
        }
        //Если положение ScrollView меньше, чем максимальное положение panel
        if (scrollY < maxScroll) {
            //Елси ScrollView ближе к максимальному значению panel
            if (scrollY >= maxScroll / 3)//Плавный скролл
                //Так как мы не можем изменять UI не из главного потока, мы используем post
                scrollView.post { scrollView.smoothScrollTo(0, maxScroll) }
            //Если ScrollView ближе к верхнему краю экрана
            else if (scrollY < maxScroll / 3)
            //Так как мы не можем изменять UI не из главного потока, мы используем post
                scrollView.post { scrollView.smoothScrollTo(0, 0) }
        }
    }.start()
}

Итог

Вот теперь это выглядит потрясающе, ну на мой сугубо личный взгляд, и мы смогли повторить ту самую идею, которую несёт в себе оболочка OneUI. Так же ли всё работает у Samsung? Конечно же нет. Но тот способ, который я описал, позволит вам лучше понять всё происходящее здесь. Так как у нас есть свои собственные отличительные черты, я предлагаю назвать наше дизайнерское решение Siesta 1.0. Надеюсь, вам поможет данная статья, т.к. в своё время, её мне очень не хватало и во всём разбираться пришлось с 0. Комментируете, если что-то не понятно, ну и конечно делитесь своим мнением, как вам моё детище и One UI.

Данное приложение вы можете найти на GitHub. Бонусом идёт пример приложения использующее Siesta 1.0, оно спрятано в подсказке.

  • Ссылка на GitHub

  • Тестовое приложение использующее Siesta 1.0

Источник: https://habr.com/ru/post/574904/


Интересные статьи

Интересные статьи

На днях я наконец-то получила свой долгожданный сертификат по работе с сервисом для генерации тестовых данных GenRocket.  И теперь как сертифицированный специалист г...
Пятничный привет, Хабр! В данной статье речь пойдет о том, как познать дзен самостоятельной сборки гаджета, о том, что любой опыт это тоже знания, а так же немного ценных советов для тех, кто ...
Как широко известно, с 1 января 2017 года наступает три важных события в жизни интернет-магазинов.
«Битрикс» — кошмар на костылях. Эта популярная характеристика системы среди разработчиков и продвиженцев ныне утратила свою актуальность.
Частенько в сети проскакивают сообщения о борьбе за экологию, развитие альтернативных источников энергии. Иногда даже проводят репортажи о том, как в заброшенной деревне сделали солнечную электро...