Zaawansowane funkcje rysika

Android i ChromeOS oferują różne interfejsy API, które pomagają tworzyć aplikacje zapewniające użytkownicy nie tylko korzystają z rysika. MotionEvent klasa ujawnia informacje o interakcji rysika z ekranem, w tym o docisku rysika, orientacji, przechylenia, po najechaniu kursorem i wykrywaniu dłoni. Grafika i ruch z małym opóźnieniem biblioteki prognoz ulepszają renderowanie rysika na ekranie, w naturalny sposób przypominający papier i pióro.

MotionEvent

Klasa MotionEvent reprezentuje interakcje wejściowe użytkownika, np. pozycję oraz ruch wskaźników dotykowych na ekranie. Do wprowadzania tekstu za pomocą rysika: MotionEvent ujawniają również dane dotyczące nacisku, orientacji, nachylenia i danych po najechaniu kursorem.

Dane zdarzenia

Aby uzyskać dostęp do danych typu MotionEvent, dodaj do komponentów modyfikator pointerInput:

@Composable
fun Greeting() {
    Text(
        text = "Hello, Android!", textAlign = TextAlign.Center, style = TextStyle(fontSize = 5.em),
        modifier = Modifier
            .pointerInput(Unit) {
                awaitEachGesture {
                    while (true) {
                        val event = awaitPointerEvent()
                        event.changes.forEach { println(it) }
                    }
                }
            },
    )
}

Obiekt MotionEvent dostarcza dane związane z tymi aspektami interfejsu użytkownika zdarzenie:

  • Działania: fizyczna interakcja z urządzeniem – dotykanie ekranu. przesuwając wskaźnik po powierzchni ekranu, najeżdżając wskaźnikiem na ekran platforma
  • Wskaźniki: identyfikatory obiektów wchodzących w interakcję z ekranem – palca rysik, mysz
  • Oś: typ danych – współrzędne X i Y, ciśnienie, nachylenie, orientacja, i najedź (odległość)

Działania

Aby zaimplementować obsługę rysika, musisz wiedzieć, co robi użytkownik skuteczności.

Funkcja MotionEvent udostępnia różne stałe ACTION, które definiują ruch zdarzeń. Najważniejsze działania związane z rysikiem to:

Działanie Opis
ACTION_DOWN
ACTION_POINTER_DOWN
Wskaźnik skontaktował się z ekranem.
PRZENIESIENIE Wskaźnik porusza się po ekranie.
ACTION_UP
ACTION_POINTER_UP
Wskaźnik nie kontaktuje się już z ekranem.
ACTION_CANCEL Kiedy ma zostać anulowany poprzedni lub bieżący ustawiony ruch.

Aplikacja może wykonywać takie zadania jak uruchamianie nowego stylu, gdy ACTION_DOWN Narysuję kreskę kreską ACTION_MOVE,, a kończę Aktywowano ACTION_UP.

Zbiór działań MotionEvent od ACTION_DOWN do ACTION_UP dla danego elementu jest nazywany zestawem ruchu.

Wskaźniki

Większość ekranów obsługuje wielodotyk: system przypisuje wskaźnik do każdego palca, rysik, mysz lub inny obiekt wskazujący, który wchodzi w interakcje z ekranem. Wskaźnik umożliwia uzyskanie informacji o osi dla określonego wskaźnika, np. położenie pierwszego palca na ekranie lub drugiego.

Indeksy wskaźników mają zakres od 0 do liczby wskaźników zwracanych przez MotionEvent#pointerCount() minus 1.

Dostęp do wartości osi wskaźników można uzyskać za pomocą metody getAxisValue(axis, pointerIndex). Jeśli indeks wskaźnika zostanie pominięty, system zwraca wartość dla pierwszej wartości wskaźnik, wskaźnik 0 (0).

Obiekty MotionEvent zawierają informacje o typie używanego wskaźnika. Ty może pobrać typ wskaźnika przez iterację za pomocą indeksów wskaźników i wywołanie getToolType(pointerIndex) .

Więcej informacji o wskaźnikach znajdziesz w sekcji Obsługa wielodotyku gestów.

Dane wejściowe rysika

Możesz filtrować dane wejściowe rysikiem za pomocą: TOOL_TYPE_STYLUS:

val isStylus = TOOL_TYPE_STYLUS == event.getToolType(pointerIndex)

Rysik może też zgłosić, że jest używany jako gumka z TOOL_TYPE_ERASER:

val isEraser = TOOL_TYPE_ERASER == event.getToolType(pointerIndex)

Dane osi rysika

ACTION_DOWN i ACTION_MOVE udostępniają dane osi dotyczące rysika, czyli x i współrzędne Y, ciśnienie, orientacja, pochylenie i uniesienie wskaźnika.

Aby umożliwić dostęp do tych danych, interfejs API MotionEvent udostępnia getAxisValue(int), gdzie parametrem jest dowolny z tych identyfikatorów osi:

Axis Zwracana wartość getAxisValue()
AXIS_X Współrzędna X zdarzenia ruchu.
AXIS_Y Współrzędna Y zdarzenia ruchu.
AXIS_PRESSURE W przypadku ekranu dotykowego lub touchpada – nacisk wywierany przez palec, rysik lub inny wskaźnik. W przypadku myszy lub kulki: 1 – gdy jest naciśnięty przycisk główny, 0 – jeśli nie jest naciśnięty.
AXIS_ORIENTATION w przypadku ekranu dotykowego lub touchpada – orientacja palca, rysika lub innego wskaźnika względem pionowej płaszczyzny urządzenia.
AXIS_TILT Kąt nachylenia rysika w radianach.
AXIS_DISTANCE Odległość rysika od ekranu.

Na przykład funkcja MotionEvent.getAxisValue(AXIS_X) zwraca współrzędną X dla argumentu za pierwszym razem.

Zobacz też Obsługa wielodotyku gestów.

Pozycja

Współrzędne x i y wskaźnika można pobrać przy użyciu tych wywołań:

Rysunek rysikiem na ekranie ze zmapowanymi współrzędnymi x i Y.
Rysunek 1. Współrzędne ekranu X i Y wskaźnika rysika.

Ciśnienie

Ciśnienie wskaźnika można pobrać za pomocą MotionEvent#getAxisValue(AXIS_PRESSURE) lub, w przypadku pierwszego wskaźnika, MotionEvent#getPressure()

Wartość nacisku w przypadku ekranów dotykowych lub touchpadów wynosi od 0 (nie ciśnienie) i 1, ale w zależności od ekranu mogą być zwracane wyższe wartości. kalibracji.

Styl rysika symbolizujący kontynuację niskiego do wysokiego ciśnienia. Styl wąski i płytki po lewej stronie wskazuje na niskie ciśnienie. Styl staje się szerszy i ciemniejszy od lewej do prawej, aż jest najszerszy, a najciemniejszy po prawej stronie, wskazując najwyższe ciśnienie.
Rysunek 2. Reprezentacja ciśnienia – niskie ciśnienie po lewej, wysokie ciśnienie po prawej.

Orientacja

Orientacja wskazuje, w którym kierunku wskazuje rysik.

Orientację wskaźnika można pobrać za pomocą getAxisValue(AXIS_ORIENTATION) lub getOrientation() (za pierwszy wskaźnik).

W przypadku rysika orientacja jest zwracana jako wartość radiana z zakresu od 0 do pi (π) w prawo lub od 0 do -pi w lewo.

Orientacja umożliwia wdrożenie rzeczywistego pędzla. Na przykład, jeśli plik rysik reprezentuje płaski pędzel, jego szerokość zależy od położenie rysika.

Rysunek 3. Rysik wskazujący w lewo około minus 0,57 radianów.

Przechylenie

Pochylenie mierzy pochylenie rysika względem ekranu.

Przechylenie zwraca kąt dodatni rysika w radianach, gdzie 0 to prostopadła do ekranu, a π/2 leży płasko na powierzchni.

Kąt nachylenia można określić za pomocą funkcji getAxisValue(AXIS_TILT) (nie ma skrótu do pierwszy wskaźnik).

Za pomocą przechylenia można uzyskać jak najbliższe rzeczywiste narzędzia, takie jak: imitując cieniowanie przechylonego ołówka.

Rysik został nachylony około 40 stopni od powierzchni ekranu.
Rysunek 4. Rysik został przechylony pod kątem około 0, 785 radianów lub o 45 stopni od prostopadłej.

Najechanie

Odległość rysika od ekranu można określić za pomocą getAxisValue(AXIS_DISTANCE) Metoda zwraca wartość od 0,0 (kontakt z ekranu) na wyższe wartości, w miarę jak rysik oddala się od ekranu. Po najechaniu kursorem odległość między ekranem a stalką (punktem) rysika zależy od od producenta ekranu i rysika. Ponieważ implementacje mogą różnią się od siebie, w przypadku funkcji o znaczeniu krytycznym dla aplikacji nie używaj precyzyjnych wartości.

Najechanie rysikiem pozwala wyświetlić podgląd rozmiaru pędzla lub wskazać, że przycisk wyboru.

Rysunek 5. Rysik najeżdżający na ekran. Aplikacja reaguje, mimo że rysik nie dotyka powierzchni ekranu.

Uwaga: w narzędziu Compose dostępne są modyfikatory, które wpływają na interaktywny stan elementów interfejsu:

  • hoverable: skonfiguruj komponent tak, aby można było najechać na niego kursorem, korzystając ze zdarzeń wejścia i wyjścia wskaźnika.
  • indication: po wystąpieniu interakcji generuje efekty wizualne dla tego komponentu.

Odrzucanie dłoni, nawigacja i niechciane dane wejściowe

Czasami ekrany wielodotykowe rejestrują niechciane dotknięcia, na przykład gdy użytkownik w naturalny sposób opiera dłoń na ekranie, aby uzyskać wsparcie podczas pisania odręcznego. Odrzucenie przez Palmę to mechanizm, który wykrywa takie zachowanie i powiadamia Cię o ostatni zbiór elementów typu MotionEvent powinien zostać anulowany.

Dlatego musisz przechowywać historię danych wejściowych użytkowników, aby uniknąć niepożądanych dotknięć można usunąć z ekranu, a dane wejściowe użytkownika mogą być wyrenderowano ponownie.

ACTION_CANCEL i FLAG_CANCELED

ACTION_CANCEL i FLAG_CANCELED jest informują, że poprzedni zestaw MotionEvent powinien zostać anulowano od ostatniego ACTION_DOWN, więc możesz na przykład cofnąć ostatnie kreska dla danego wskaźnika w aplikacji do rysowania.

ACTION_CANCEL

Dodane w Androidzie 1.0 (poziom API 1)

ACTION_CANCEL wskazuje, że poprzedni zestaw zdarzeń ruchu powinien zostać anulowany.

Funkcja ACTION_CANCEL jest wyzwalana, gdy zostanie wykryty dowolny z tych elementów:

  • Gesty do nawigacji
  • Odrzucenie palm

Po wywołaniu funkcji ACTION_CANCEL aktywny wskaźnik należy wskazać za pomocą getPointerId(getActionIndex()) Następnie usuń kreskę utworzoną przy użyciu tego wskaźnika z historii wprowadzania i ponownie wyrenderuj scenę.

FLAG_CANCELED (ANULOWANO)

Dodano w Androidzie 13 (poziom API 33)

FLAG_CANCELED wskazuje, że wzrost wskaźnika jest wynikiem niezamierzonego dotknięcia użytkownika. Flaga to zwykle ustawia się, gdy użytkownik przypadkowo dotyka ekranu, np. przez lub położyć dłoń na ekranie.

Dostęp do wartości flagi możesz uzyskać w ten sposób:

val cancel = (event.flags and FLAG_CANCELED) == FLAG_CANCELED

Jeśli flaga jest ustawiona, trzeba cofnąć ostatnie ustawienie MotionEvent, od ostatniego ACTION_DOWN od tego wskaźnika.

Podobnie jak ACTION_CANCEL, wskaźnik można znaleźć w getPointerId(actionIndex).

Rysunek 6. Styl rysika i dotyk dłoni tworzą zestawy MotionEvent. Dotyk dłoni jest anulowany, a wyświetlacz jest ponownie renderowany.

Pełnoekranowy, obraz od krawędzi do krawędzi i gesty nawigacji

Jeśli aplikacja działa na pełnym ekranie i znajduje się obok niej interaktywne elementy, takie jak obszaru roboczego aplikacji do rysowania lub robienia notatek. Przesuń palcem od dołu ekranu, wyświetlanie nawigacji lub przeniesienie aplikacji w tle może spowodować niechciany dotyk obszaru roboczego.

Rysunek 7. Gest przesuwania, aby przenieść aplikację w tle.

Aby gesty nie powodowały niepożądanych dotknięć w aplikacji, możesz: zaletą zestawów i ACTION_CANCEL

Zobacz też artykuł Odrzucanie, nawigacja i niechciane dane wejściowe w Palm. .

Użyj setSystemBarsBehavior() BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE z WindowInsetsController aby zapobiec niechcianym dotknięciu za pomocą gestów nawigacyjnych:

// Configure the behavior of the hidden system bars.
windowInsetsController.systemBarsBehavior =
    WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE

Więcej informacji o zarządzaniu wstawianiem i gestami znajdziesz tutaj:

Małe opóźnienie

Czas oczekiwania to czas potrzebny na przetworzenie przez sprzęt, system i aplikację i renderować dane wejściowe użytkownika.

Czas oczekiwania = przetwarzanie sprzętowe i wejściowe systemu operacyjnego + przetwarzanie aplikacji + komponowanie systemu

  • renderowanie sprzętowe
Z powodu opóźnienia renderowane pociągnięcie jest opóźnione w stosunku do pozycji rysika. Luka między wyrenderowanym kreską a położeniem rysika reprezentuje opóźnienie.
Rysunek 8. Z powodu opóźnienia renderowane pociągnięcie jest opóźnione w stosunku do pozycji rysika.

Źródło czasu oczekiwania

  • Rejestrowanie rysika na ekranie dotykowym (sprzęt): wstępne połączenie bezprzewodowe gdy rysik i system operacyjny mają komunikować się, aby zarejestrować i zsynchronizować.
  • Częstotliwość próbkowania ekranu dotykowego (sprzęt): liczba wyświetleń ekranu dotykowego na sekundę. sprawdza, czy wskaźnik dotyka powierzchni. Częstotliwość odświeżania w zakresie od 60 do 1000 Hz.
  • Przetwarzanie danych wejściowych (aplikacja): stosowanie kolorów, efektów graficznych i przekształcenia na dane wejściowe użytkownika.
  • Renderowanie graficzne (system operacyjny i sprzęt): wymiana buforów, przetwarzanie sprzętowe.

Obrazy z małym opóźnieniem

Biblioteka grafiki z małym opóźnieniem w Jetpack skraca czas przetwarzania danych między danymi wpisywanymi przez użytkownika a renderowaniem na ekranie.

Biblioteka skraca czas przetwarzania dzięki unikaniu renderowania wielobuforowego i wykorzystując technikę renderowania „frontend bufora”, co oznacza pisanie bezpośrednio na ekranie.

Renderowanie bufora przedniego

Przedni bufor to pamięć używana przez ekran do renderowania. Jest najbliższy aplikacje mogą korzystać z rysunku bezpośrednio na ekranie. Biblioteka niskiego opóźnienia umożliwia do renderowania bezpośrednio w przednim buforze. Poprawia to wydajność o zapobiega zamienianiu buforów, co może mieć miejsce w przypadku standardowego renderowania z wieloma buforami. lub renderowanie z podwójnym buforem (najczęstszym przypadkiem).

Aplikacja zapisuje w buforze ekranu i odczytuje z bufora ekranu.
Rysunek 9. Renderowanie bufora przedniego.
Aplikacja zapisuje do wielu buforów, które są zastępowane buforem ekranu. Aplikacja odczytuje dane z bufora ekranu.
Rysunek 10. Renderowanie z wieloma buforami.

Chociaż renderowanie przedniego bufora to doskonała technika renderowania małego obszaru ekranu, nie służy do odświeżania całego ekranu. Na renderowanie bufora przedniego, aplikacja renderuje treść w buforze, z którego co czyta wyświetlacz. W rezultacie istnieje możliwość renderowania i zerwania (patrz poniżej).

Biblioteka o krótkim czasie oczekiwania jest dostępna na Androidzie 10 (poziom interfejsu API 29) i nowszych oraz na urządzeniach z ChromeOS z Androidem 10 (poziom interfejsu API 29) lub nowszym.

Zależności

Biblioteka o małych opóźnieniach zapewnia komponenty renderowania frontendu implementacji. Biblioteka jest dodawana jako zależność w module aplikacji. Plik build.gradle:

dependencies {
    implementation "androidx.graphics:graphics-core:1.0.0-alpha03"
}

Wywołania zwrotne GLFrontBufferRenderer

Biblioteka niskiego opóźnienia zawiera GLFrontBufferRenderer.Callback który definiuje następujące metody:

W bibliotece niskiego opóźnienia nie ma znaczenia, jakiego typu dane używasz GLFrontBufferRenderer

Biblioteka przetwarza jednak dane w postaci strumienia setek punktów danych, a potem projektować dane tak, aby zoptymalizować wykorzystanie i alokację pamięci.

Wywołania zwrotne

Aby włączyć renderowanie wywołań zwrotnych, zaimplementuj GLFrontBufferedRenderer.Callback i zastąp wartości onDrawFrontBufferedLayer() i onDrawDoubleBufferedLayer(). GLFrontBufferedRenderer używa wywołań zwrotnych, aby maksymalnie renderować dane w sposób optymalizowany.

val callback = object: GLFrontBufferedRenderer.Callback<DATA_TYPE> {
   override fun onDrawFrontBufferedLayer(
       eglManager: EGLManager,
       bufferInfo: BufferInfo,
       transform: FloatArray,
       param: DATA_TYPE
   ) {
       // OpenGL for front buffer, short, affecting small area of the screen.
   }
   override fun onDrawMultiDoubleBufferedLayer(
       eglManager: EGLManager,
       bufferInfo: BufferInfo,
       transform: FloatArray,
       params: Collection<DATA_TYPE>
   ) {
       // OpenGL full scene rendering.
   }
}
Zadeklarowanie wystąpienia komponentu GLFrontBufferedRenderer

Przygotuj dokumenty: GLFrontBufferedRenderer, podając SurfaceView i utworzonych wcześniej wywołań zwrotnych. GLFrontBufferedRenderer optymalizuje renderowanie na przód i do podwójnego buforowania przy użyciu wywołań zwrotnych:

var glFrontBufferRenderer = GLFrontBufferedRenderer<DATA_TYPE>(surfaceView, callbacks)
Renderowanie

Renderowanie frontendu bufora rozpoczyna się, gdy wywołasz funkcję renderFrontBufferedLayer() , która wywołuje wywołanie zwrotne onDrawFrontBufferedLayer().

Renderowanie z podwójnym buforowaniem jest wznawiane, gdy wywołasz funkcję commit() , która wywołuje wywołanie zwrotne onDrawMultiDoubleBufferedLayer().

W przykładzie poniżej proces jest renderowany w przednim buforze (szybko renderowanie), gdy użytkownik zacznie rysować na ekranie (ACTION_DOWN) i się porusza. wskaźnik dookoła (ACTION_MOVE). Proces renderuje się w podwójnym buforze gdy wskaźnik opuści powierzchnię ekranu (ACTION_UP).

Za pomocą requestUnbufferedDispatch() , by system wejściowy nie grupował zdarzeń ruchu, lecz zamiast tego gdy tylko będą dostępne:

when (motionEvent.action) {
   MotionEvent.ACTION_DOWN -> {
       // Deliver input events as soon as they arrive.
       view.requestUnbufferedDispatch(motionEvent)
       // Pointer is in contact with the screen.
       glFrontBufferRenderer.renderFrontBufferedLayer(DATA_TYPE)
   }
   MotionEvent.ACTION_MOVE -> {
       // Pointer is moving.
       glFrontBufferRenderer.renderFrontBufferedLayer(DATA_TYPE)
   }
   MotionEvent.ACTION_UP -> {
       // Pointer is not in contact in the screen.
       glFrontBufferRenderer.commit()
   }
   MotionEvent.CANCEL -> {
       // Cancel front buffer; remove last motion set from the screen.
       glFrontBufferRenderer.cancel()
   }
}

Co robić, a czego unikać w przypadku renderowania

✓ Tak

Małe fragmenty ekranu, pismo odręczne, rysunek, szkicowanie.

✗ Nie

Aktualizacja na pełnym ekranie, przesuwanie, powiększanie. Może dojść do rozerwania.

Rozdarcie

Zerwanie ma miejsce, gdy ekran odświeża się podczas buforowania ekranu zmodyfikowane w tym samym czasie. Na jednej części ekranu widać nowe dane, a na innej pokazuje stare dane.

Górne i dolne części obrazu Androida są niewłaściwie wyrównane z powodu rozerwania się podczas odświeżania ekranu.
Rysunek 11. Zerwanie podczas odświeżania ekranu od góry do dołu

Przewidywanie ruchu

Jetpack: prognozowanie ruchu biblioteka zmniejsza przewidywane opóźnienie dzięki oszacowaniu ścieżki ruchowej użytkownika i tymczasowym, sztucznie wskazuje mechanizm renderowania.

Biblioteka prognozowania ruchu pobiera dane wejściowe użytkownika jako obiekty MotionEvent. Obiekty zawierają informacje o współrzędnych x i y, ciśnieniu i czasie, które są wykorzystywane przez prognozy ruchu do prognozowania przyszłych MotionEvent obiektów.

Prognozowane obiekty MotionEvent są tylko szacunkami. Prognozowane zdarzenia mogą zmniejszyć widoczny czas oczekiwania, ale dane prognozowane muszą zostać zastąpione rzeczywistymi danymi MotionEvent danych po ich otrzymaniu.

Biblioteka prognozowania ruchu jest dostępna na Androidzie 4.4 (poziom interfejsu API 19) oraz oraz na urządzeniach z ChromeOS z Androidem 9 (poziom interfejsu API 28) lub nowszym.

Z powodu opóźnienia renderowane pociągnięcie jest opóźnione w stosunku do pozycji rysika. Luka między kreską a rysikiem jest wypełniona punktami prognozy. Pozostała przerwa to spodziewany czas oczekiwania.
Rysunek 12. Czas oczekiwania został zmniejszony przez przewidywanie ruchu.

Zależności

Implementację prognozowania umożliwia biblioteka prognozowania ruchu. biblioteka jest dodawana jako zależność w pliku build.gradle modułu aplikacji:

dependencies {
    implementation "androidx.input:input-motionprediction:1.0.0-beta01"
}

Implementacja

Biblioteka prognozowania ruchu obejmuje MotionEventPredictor który definiuje następujące metody:

  • record(): Przechowuje obiekty MotionEvent w rejestrze działań użytkownika
  • predict(): Zwraca przewidywaną wartość MotionEvent
Zadeklaruj wystąpienie instancji MotionEventPredictor
var motionEventPredictor = MotionEventPredictor.newInstance(view)
Dodaj dane do prognozowania
motionEventPredictor.record(motionEvent)
Prognoza

when (motionEvent.action) {
   MotionEvent.ACTION_MOVE -> {
       val predictedMotionEvent = motionEventPredictor?.predict()
       if(predictedMotionEvent != null) {
            // use predicted MotionEvent to inject a new artificial point
       }
   }
}

Zalecenia i ograniczenia związane z prognozowaniem ruchu

✓ Tak

Usuń punkty prognozy po dodaniu nowego przewidywanego punktu.

✗ Nie

Nie używaj punktów prognozy do ostatecznego renderowania.

Notatki

ChromeOS umożliwia aplikacji deklarowanie niektórych działań związanych z robieniem notatek.

Aby zarejestrować aplikację jako aplikację do robienia notatek w ChromeOS, zobacz Wprowadzanie danych .

Aby zarejestrować aplikację jako aplikację do robienia notatek na Androidzie, przeczytaj artykuł Tworzenie notatek.

w Androidzie 14 (poziom API 34) wprowadziliśmy ACTION_CREATE_NOTE intencję, która umożliwia aplikacji rozpoczęcie aktywności związanej z robieniem notatek ekranu.

Rozpoznawanie cyfrowego atramentu przy użyciu ML Kit

Z użyciem atramentu cyfrowego ML Kit rozpoznawanie twarzy, jest w stanie rozpoznać odręczny tekst na cyfrowej przestrzeni w setkach języki. Możesz też klasyfikować szkice.

ML Kit zapewnia Ink.Stroke.Builder klasa do tworzenia obiektów Ink, które mogą być przetwarzane przez modele systemów uczących się , by zamienić pismo odręczne na tekst.

Oprócz rozpoznawania pisma odręcznego model rozpoznaje też gestów, na przykład usuń i zakreśl.

Zobacz Atrament cyfrowy rozpoznawanie aby dowiedzieć się więcej.

Dodatkowe materiały

Przewodniki dla programistów

Ćwiczenia z programowania