Aplikacje adaptacyjne

Dymki czatu – jak wygląda to od kuchni?

8 minut czytania
Komentarze

Witajcie ponownie. Jakiś czas temu opisywałem moją aplikację pisaną w ramach samorozwoju. Jedną z jej funkcjonalności jest pływająca ikona, dzięki której można włączyć czarną powłokę, nakładaną na aktualnie uruchomioną aplikację. Jednak dzisiaj chciałbym się skupić jedynie na pływającej ikonie. Funkcjonalność ta jest znana szerzej jako „Dymki czatu” z Messengera. W tym artykule dowiecie się, jak to wygląda z perspektywy programisty, jakie zmiany w tym temacie wprowadzał Android, a także co nas czeka w przyszłości.

Dymki czatu nie są wbudowanym mechanizmem*

dymki czatu

W większość popularnych komunikatorów funkcjonalność ta wygląda bardzo podobnie. Jest sobie jakaś ikona, widoczna na każdym ekranie, po kliknięciu, w którą otwiera nam się widok czatu. Z perspektywy użytkownika Androida mogłoby nam się wydawać, że jest to jakaś wbudowana funkcjonalność. Wystarczy, że programista określi ikonę, akcję po kliknięciu i tyle, a resztą zajmie się system.

Otóż jak brzmi klasyk, nie tym razem. Programiści, którzy jako pierwsi stworzyli tę funkcjonalność (prawdopodobnie pracownicy Facebooka, lecz brak jednoznacznych źródeł, kto był pierwszy) wykazali się niemałym sprytem. Wygrzebali pewną niepozorną opcję i stworzyli z niej coś tak fajnego.

Serwis wyświetlający interfejs użytkownika to podstawa dla dymków czatu

dymki czatu

Za każdym bąbelkiem lub innym widokiem wyświetlanym „ponad” innymi aplikacjami stoi pracujący w pocie czoła androidowy serwis – pomyśl o nim, pisząc ze znajomymi na Messengerze.

Nie wszyscy zdają sobie sprawę, ale za pomocą WindowManagera możemy wyświetlić dowolny widok. Nie musi to być mała ikona, możemy nawet zasłonić cały ekran tak jak w przypadku mojej aplikacji. No dobrze, ale jak to zrobić?

val layoutParams = WindowManager.LayoutParams(
            width = WRAP_CONTENT,
            height = WRAP_CONTENT,
            type = WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY,
            flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
            format = PixelFormat.TRANSLUCENT
        )

val bubbleView = LayoutInflater.from(this).inflate(
            R.layout.layout_bubble,
            null
        )

windowManager.addView(viewCollapsed, layoutParams)

W celu poprawienia czytelności dodałem nazwy argumentów przy wywołania konstruktora LayoutParams. Żeby skompilować taki kod, będziesz musiał najpierw usunąć te nazwy, ponieważ nie jest to konstruktor napisany w kotlinie.

Najważniejszy fragment to tworzenie obiektu LayoutParams, a właściwie wartość, jaką nadaliśmy dla parametru type. Aż do Androida 7.1 (API 25), mogliśmy używać flagi TYPE_SYSTEM_OVERLAY. Dawała nam ona dość duże i niebezpieczne uprawnienia. Mogliśmy na przykład całkowicie zasłonić powiadomienia oraz wszystkie inne aplikacje. Przez co telefon stawał się chwilowo nie do użycia.

Działanie flagi TYPE_SYSTEM_OVERLAY w praktyce

Na szczęście począwszy od Androida 8.0 (API 26), flagi TYPE_SYSTEM_OVERLAY mogą używać tylko aplikacje systemowe. Programiści mogą korzystać jedynie z TYPE_APPLICATION_OVERLAY, która daje możliwość rysowania tylko ponad innymi aplikacjami, lecz nie ponad elementami systemu. Dzięki temu powyższa sytuacja nie jest już możliwa.

Działanie flagi TYPE_APPLICATION_OVERLAY w praktyce

Co z moim bąbelkiem?

Tak jak wspomniałem wyżej, z poziomu serwisu możemy wyświetlić dowolny widok. Oczywiście daje nam to ogromną wolność, lecz zmusza też do pewnego wysiłku, jeśli chcemy osiągnąć efekt pływającego bąbelka.

Podczas pracy nad moją aplikacją sporo czasu spędziłem nad tym, aby doprowadzić cały mechanizm do przyzwoitego działania. Serwis w Media Battery Saver składa się z dwóch layoutów:

  • Pełnoekranowego z pływającą ikonką i przezroczystym tłem
  • Pełnoekranowego z przyciskami sterowania i czarnym tłem
Layout z pływającą ikonką
Layout z przyciskami sterowania

Do takiego stanu doszedłem metodą prób i błędów. W pierwszym podejściu próbowałem dodawać do WindowManager po prostu ImageView z ikonką. Początkowo wszystko było dobrze – problem zaczął się w momencie, gdy kilkukrotnie zmienialiśmy wysokość i szerokość w LayoutParams z WRAP_CONTENT na MATCH_PARENT i z powrotem (w celu pokazywania i ukrywania czarnej powłoki). Dlatego ostatecznie zdecydowałem się na używanie tylko pełnoekranowych layoutów.

Bąbelek widoczny na głównym ekranie

Co z ruchem i zapamiętywaniem pozycji dymka?

To chyba była najtrudniejsza część. Istnieje wiele szkół, w jaki sposób interpretować i odzwierciedlać ruch. Tutaj również wypracowałem pewne rozwiązanie metodą prób i błędów.

Zacznijmy może od tego, w jaki sposób wykryć ruch użytkownika. Ja ostatecznie zdecydowałem się na przypisanie OnTouchListenera do pływającej ikonki. Rzecz wydaje się z pozoru prosta, mamy przecież MotionEvent.ACTION_MOVE to zdarzenie wprost informuje nas, gdy palec użytkownika się przesunął po kliknięciu naszej ikonki. Niestety to nie wystarczy, bo ekrany dotykowe są dość czułe i dostajemy to zdarzenie, nawet jeśli palec ruszył się tylko o wartość jednego piksela. Nie byłoby to przeszkodą, gdyby nie to, że chcielibyśmy, aby nasz ikonka była klikalna. Przy użyciu tylko ACTION_MOVE praktycznie nie da się kliknąć, na ikonkę bez jej przesuwania.

Jak ja to rozwiązałem? Zdecydowałem się na zastosowanie pewnego marginesu, obecnie wynosi on 6% wielkości ekranu. Czyli jeśli mamy ekran Full HD, to mamy do czynienia z kliknięciem, jeśli palec przesunie się nie więcej niż 65 pikseli w poziomie i 115 w pionie. Nie jest to nic trudnego, wystarczy w ACTION_DOWN zapisać współrzędne, od których zaczęliśmy ruch, a następnie w ACTION_MOVE sprawdzać, czy przesunięcie jest większe, niż nasz określony margines.

private fun shouldBeMoved(
        initialX: Float, initialY: Float, currentX: Float, currentY: Float,
        viewWidth: Int, viewHeight: Int
    ): Boolean {
        val xDifference = abs(initialX - currentX)
        val yDifference = abs(initialY - currentY)
        val xDifferenceInPercent = xDifference / viewWidth.toFloat() * 100f
        val yDifferenceInPercent = yDifference / viewHeight.toFloat() * 100f

        return (xDifferenceInPercent > percentDifferenceToMove) or (yDifferenceInPercent > percentDifferenceToMove)
    }

Zapisywanie pozycji to również żadna magia. U siebie po każdym ruchu zapisuje współrzędne w SharedPreferences

Super, wiemy już jak wykryć ruch ikonki, teraz najważniejsze pytanie, czyli jak ten ruch pokazać? Tutaj też są różne szkoły. Według mnie najprościej jest przypiąć nasz widok do lewego górnego rogu i ustawiać odpowiednie marginesy. Dlaczego? System Android wszystkie współrzędne podaje właśnie licząc od lewego górnego rogu. Tak więc jeśli przesunięcie nastąpi do punktu (100, 200), to wystarczy ustawić margines od lewej o wartości 100 px oraz od góry o wartości 200 px.

private fun setViewPosition(x: Int, y: Int) {
        val layoutParams = bubbleView.layoutParams as WindowManager.LayoutParams
        layoutParams.gravity = Gravity.TOP or Gravity.START
        layoutParams.x = x
        layoutParams.y = y

        windowManager.updateViewLayout(bubbleView, layoutParams)
    }

Czy to musi być tak skomplikowane?

Google w Androidzie Q zaprezentował Bubble API, czyli uproszczoną wersję tego, o czym pisałem wcześniej. Jednak opcja ta była dostępna tylko po uruchomieniu jej w opcjach programisty (stąd ta gwiazdka przy pierwszej sekcji ;)). Niemniej jest duże prawdopodobieństwo, że funkcjonalność ta zostanie uruchomiona dla wszystkich w Androidzie R.

Jak to wygląda w praktyce? W celu wyświetlenie naszej zawartości wystarczy tylko taki fragment kodu:

private fun showBubbleNotification() {
        val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

        val channelId = createChannel(notificationManager)

        val target = Intent(this, BubblesActivity::class.java)
        val bubbleIntent = PendingIntent.getActivity(this, 0, target, 0 /* flags */)

        val bubbleData = Notification.BubbleMetadata.Builder()
                .setDesiredHeight(600)
                .setIcon(Icon.createWithResource(this, R.drawable.ic_logo))
                .setIntent(bubbleIntent)
                .build()

        val chatBot = Person.Builder()
                .setBot(true)
                .setName("BubbleBot")
                .setImportant(true)
                .build()

        val builder = Notification.Builder(this, channelId)
                .setContentIntent(bubbleIntent)
                .setSmallIcon(R.drawable.ic_logo)
                .setBubbleMetadata(bubbleData)
                .addPerson(chatBot)

        notificationManager.notify(1, builder.build())
    }

    private fun createChannel(notificationManager: NotificationManager): String {
        val id = "bubbleChannel"
        val name = "BUBBLE_CHANNEL"
        val descriptionText = "Test bubble channel"
        val importance = NotificationManager.IMPORTANCE_MIN
        val channel = NotificationChannel(id, name, importance)
        channel.description = descriptionText

        notificationManager.createNotificationChannel(channel)

        return id
    }

Jak widzicie, bąbelek to tak naprawdę powiadomienie, które wyświetla się w specyficzny sposób. Po jego kliknięciu otworzy nam się BubbleActivity, przechodząc przez normalny cykl życia aktywności. Oczywiście nie otworzy się nam w sposób tradycyjny, zamiast tego będzie osadzone w pływającym oknie, ponad innymi aplikacjami.

Przy konstrukcji takiego bąbelka musimy pamiętać, żeby skonfigurować je też w taki sam sposób, w jaki konfigurujemy tradycyjne powiadomienia. Wszystko dlatego, że użytkownik w każdej chwili może wyłączyć bąbelki w ustawieniach. Wtedy nasze powiadomienie zostanie wyświetlone na górnej belce, razem z innymi notyfikacjami.

dymki czatu bubble api

Jak widać, Bubble API jest kierowane głównie do aplikacji, będących komunikatorami. W przypadku programów jak mój, gdzie nie ma żadnych powiadomień, a powłoka jest główną funkcjonalnością, takie coś nie zda egzaminu.

Jakie jest moje zdanie o Bubble API?

Z jednej strony cieszę się, że Google ułatwia pracę programistom, którzy tworzyli różnego rodzaju komunikatory. Z drugiej jednak obawiam się o przyszłość aplikacji, które kreatywnie wykorzystywały funkcjonalność rysowania ponad innymi aplikacjami.

Dlaczego się obawiam? Do wyświetlania widoków ponad innymi aplikacjami potrzebujemy specjalnego pozwolenia android.permission.SYSTEM_ALERT_WINDOW. Nie dotyczy to jednak przypadku, w którym korzystamy z Bubble API. Jeśli to rozwiązanie będzie się cieszyło powodzeniem wśród programistów, w przyszłości Google może odebrać możliwość uzyskania pozwolenia SYSTEM_ALERT_WINDOW, co zakończy działalność wielu aplikacji. Obawa jest mocno uzasadniona, ponieważ już w Androidzie 10 GO nie możemy uzyskać tego pozwolenia.

Trochę optymizmu

Tamten akapit zakończyłem nieco pesymistyczną wizją, jednak to są tylko przewidywania. Mam nadzieję, że w przyszłości będzie miejsce zarówno dla aplikacji korzystających z SYSTEM_ALERT_WINDOW jak i Bubble API. Zaznaczam również, że nie ma jeszcze finalnej wersji Androida 11, dlatego miejcie na uwadze, że jeszcze wiele rzeczy może się zmienić.

To już koniec na dziś. Dzięki za dotrwanie do końca. Pamiętajcie o pozostałych artykułach z serii Programowanie oraz o naszym dziale na forum, na którym ostatnio bardzo dużo się dzieje. Do zobaczenia!

Motyw