Лабораторная работа 9

Тема: Разработка мобильного приложения, часть 2.

Задание:

Ход работы:

  1. Создаем SplashActivty, который делаем стартовым activity нашего приложения.

  2. Экспортируем из AdobeXD созданный splash screen (выделяем artboard и экспортируем его в формате SVG).

  3. Добавляем svg в drawables, после чего устанавливаем его как background макета Splash Activity.

  4. Устанавливаем стиль в themes.xml, который позволяет убрать actionbar, устанавливаем statusbar в цвет primary, а также настраиваем цветовую палитру

themes.xml
<resources xmlns:tools="http://schemas.android.com/tools">
    <style name="Theme.Give" parent="Theme.MaterialComponents.DayNight.NoActionBar">

        <item name="colorPrimary">@color/primaryColor</item>
        <item name="colorPrimaryVariant">@color/primaryDarkColor</item>
        <item name="colorOnPrimary">@color/primaryTextColor</item>

        <item name="colorSecondary">@color/secondaryColor</item>
        <item name="colorSecondaryVariant">@color/secondaryDarkColor</item>
        <item name="colorOnSecondary">@color/secondaryTextColor</item>

        <item name="android:colorBackground">@color/white</item>
        <item name="colorSurface">@color/white</item>
        <item name="colorError">@color/red_600</item>

        <item name="colorOnBackground">@color/black</item>
        <item name="colorOnSurface">@color/black</item>
        <item name="colorOnError">@color/white</item>

        <item name="android:statusBarColor">@color/primaryColor</item>
    </style>
</resources>
  1. Добавляем код, который позволяет создать полноэкранный activity, а также скрываем actionbar

  2. Делаем заглушку для перехода из SplashScreen в MainActivity. В нашем случае произойдет безусловный переход через секунду

SplashActivity.java
public class SplashActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        setContentView(R.layout.activity_splash);

        new Handler().postDelayed(() -> {
            Intent i = new Intent(this, MainActivity.class);
            startActivity(i);
        }, 1000);
    }
}

Также устанавливаем navigationbar одного цвета с фоном

public class SplashActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        getWindow().setNavigationBarColor(getColor(R.color.primaryColor));
        setContentView(R.layout.activity_splash);

        new Handler().postDelayed(() -> {
            Intent i = new Intent(this, MainActivity.class);
            startActivity(i);
        }, 1000);
    }
}

Далее переходим в MainAcitivity. Нам необходимо создать нижнее меню как в макете

Такой виджет называется BottomAppBar и он работает в паре с CoordinatorLayout. Добавляем в макет CoordinatorLayout, добавляем BottomAppBar, добавляем FAB и устанавливаем привязку к BottomAppBar.

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".main.MainActivity">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Hello World!"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    </androidx.constraintlayout.widget.ConstraintLayout>

    <com.google.android.material.bottomappbar.BottomAppBar
        android:id="@+id/bottomAppBar"
        app:backgroundTint="@color/primaryColor"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_anchor="@id/bottomAppBar"
        android:src="@drawable/fab_logo"
        app:maxImageSize="40dp"
        app:tint="@color/white"
        app:rippleColor="@color/secondaryDarkColor"/>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

Добавляем в drawables вектор логотипа и устанавливаем его как кнопку для FAB. В MainActivity.java устанавливаем navigatiobar одного цвета с primary color.

MainActivity.java
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getWindow().setNavigationBarColor(getColor(R.color.primaryColor));
        setContentView(R.layout.activity_main);
    }
}

С помощью атрибутов app:fab... устанавливаем нужное положение fab внутри bottomAppBar.

<com.google.android.material.bottomappbar.BottomAppBar
    android:id="@+id/bottomAppBar"
    app:backgroundTint="@color/primaryColor"
    android:layout_width="match_parent"
    app:fabCradleVerticalOffset="0dp"
    app:fabCradleRoundedCornerRadius="16dp"
    app:fabCradleMargin="8dp"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom" />

Добавляем иконки и меню для BottomAppBar. Если устанавливать иконки в виде обычного меню, они будут располагаться справа, как пункты меню в toolbar. На наше счастье BottomAppBar поддерживает возможность установить собственный макет, чем мы и воспользуемся.

    <com.google.android.material.bottomappbar.BottomAppBar
        android:id="@+id/bottomAppBar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        app:backgroundTint="@color/primaryColor"
        app:contentInsetLeft="0dp"
        app:contentInsetStart="0dp"
        android:gravity="fill"
        android:paddingBottom="0dp"
        app:fabCradleRoundedCornerRadius="16dp"
        app:fabCradleVerticalOffset="0dp">

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

            <ImageButton
                android:id="@+id/map"
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:background="?attr/selectableItemBackgroundBorderless"                android:gravity="center"
                android:src="@drawable/ic_map" />

            <ImageButton
                android:id="@+id/list"
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:background="?attr/selectableItemBackgroundBorderless"                android:gravity="center"
                android:src="@drawable/ic_list" />

            <ImageButton
                android:id="@+id/dummy_image"
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:visibility="invisible"
                android:background="?attr/selectableItemBackgroundBorderless"                android:gravity="center"
                android:src="@drawable/ic_map" />

            <ImageButton
                android:id="@+id/notifications"
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:background="?attr/selectableItemBackgroundBorderless"                android:gravity="center"
                android:src="@drawable/ic_notification" />

            <ImageButton
                android:id="@+id/account"
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:background="?attr/selectableItemBackgroundBorderless"                android:gravity="center"
                android:src="@drawable/ic_account" />

        </LinearLayout>
    </com.google.android.material.bottomappbar.BottomAppBar>

При запуске приложения на некоторых телефонах обнаружено, что изображение splash screen сжимается по ширине. Чтобы это исправить, переделаем splash screen следующим образом

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/primaryColor"
    tools:context=".splash.SplashActivity">

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@drawable/fab_logo"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Фрагменты и меню

Теперь добавим фрагменты и карту для нашего приложения. В activity_main добавим viewpager2 на весь экран

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".main.MainActivity">

        <androidx.viewpager2.widget.ViewPager2
            android:id="@+id/pager"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

Далее создадим 4 фрагмента, которые будут листаться во ViewPager2 (MapFragment, ListFragment, NotificationsFragment, AccountFragment) и макеты к ним.

Перейдем в MainActivity и настроим pager и листание фрагментов. Сначала добавим адаптер и фрагменты для pager.

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getWindow().setNavigationBarColor(getColor(R.color.primaryColor));
        setContentView(R.layout.activity_main);

        ViewPager2 pager = findViewById(R.id.pager);
        Adapter adapter = new Adapter(this);
        adapter.addFragment(new MapFragment());
        adapter.addFragment(new ListFragment());
        adapter.addFragment(new NotificationsFragment());
        adapter.addFragment(new AccountFragment());
        pager.setAdapter(adapter);
        pager.setCurrentItem(0);
        pager.setUserInputEnabled(false);
    }

    private static class Adapter extends FragmentStateAdapter {
        private final List<Fragment> list = new ArrayList<>();

        public void addFragment(Fragment fragment) {
            list.add(fragment);
        }

        public Adapter(FragmentActivity fa) {
            super(fa);
        }

        @NonNull
        @Override
        public Fragment createFragment(int position) {
            return list.get(position);
        }

        @Override
        public int getItemCount() {
            return list.size();
        }
    }
}

Далее настроим переключение пунктов меню

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getWindow().setNavigationBarColor(getColor(R.color.primaryColor));
        setContentView(R.layout.activity_main);

        ImageButton mapButton = findViewById(R.id.map);
        mapButton.setColorFilter(getColor(R.color.secondaryColor));

        ImageButton listButton = findViewById(R.id.list);
        ImageButton notificationsButton = findViewById(R.id.notifications);
        ImageButton accountButton = findViewById(R.id.account);

        mapButton.setOnClickListener(v -> {
            mapButton.setColorFilter(getColor(R.color.secondaryColor));
            listButton.setColorFilter(getColor(R.color.white));
            notificationsButton.setColorFilter(getColor(R.color.white));
            accountButton.setColorFilter(getColor(R.color.white));

            pager.setCurrentItem(0,false);
        });

        listButton.setOnClickListener(v -> {
            mapButton.setColorFilter(getColor(R.color.white));
            listButton.setColorFilter(getColor(R.color.secondaryColor));
            notificationsButton.setColorFilter(getColor(R.color.white));
            accountButton.setColorFilter(getColor(R.color.white));
            pager.setCurrentItem(1,false);
        });

        notificationsButton.setOnClickListener(v -> {
            mapButton.setColorFilter(getColor(R.color.white));
            listButton.setColorFilter(getColor(R.color.white));
            notificationsButton.setColorFilter(getColor(R.color.secondaryColor));
            accountButton.setColorFilter(getColor(R.color.white));

            pager.setCurrentItem(2,false);

        });

        accountButton.setOnClickListener(v -> {
            mapButton.setColorFilter(getColor(R.color.white));
            listButton.setColorFilter(getColor(R.color.white));
            notificationsButton.setColorFilter(getColor(R.color.white));
            accountButton.setColorFilter(getColor(R.color.secondaryColor));
            pager.setCurrentItem(3,false);
        });
    }
}

Карта

Переходим в Google Cloud Platform https://console.cloud.google.com/

Добавляем новый проект (APIs & Services) -> Overview -> Create Project

В Android Studio в SDK скачиваем google play services

Добавляем в build.gradle зависимость

implementation 'com.google.android.gms:play-services-maps:17.0.1'

Добавляем в файл манифеста разрешение на получение местоположения устройства и интернет

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.INTERNET" />

Также в файл манифеста добавляем ссылку на ключ google api

AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="ua.opu.give">
    
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.Give">
        
        <meta-data
            android:name="com.google.android.geo.API_KEY"
            android:value="@string/google_maps_key" />

        <activity android:name=".splash.SplashActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <activity android:name=".main.MainActivity" />
    </application>
</manifest>

В ресурсах values создаем ресурс google_maps_api.xml, в котором добавляем ключ, полученный из Google Cloud Platform

В классе MapFragment добавляем код для гугл карт

public class MapFragment extends Fragment {

    private OnMapReadyCallback callback = new OnMapReadyCallback() {

        @Override
        public void onMapReady(GoogleMap googleMap) {
            LatLng odessa = new LatLng(	46.482952, 30.712481);
            googleMap.addMarker(new MarkerOptions().position(odessa).title("Marker in Odessa"));
            googleMap.moveCamera(CameraUpdateFactory.newLatLng(odessa));
        }
    };

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater,
                             @Nullable ViewGroup container,
                             @Nullable Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_map, container, false);
    }

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        SupportMapFragment mapFragment =
                (SupportMapFragment) getChildFragmentManager().findFragmentById(R.id.map);
        if (mapFragment != null) {
            mapFragment.getMapAsync(callback);
        }
    }
}

Макет фрагмента

<?xml version="1.0" encoding="utf-8"?>
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/map"
    android:name="com.google.android.gms.maps.SupportMapFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".main.MapFragment" />

AppRepository, ViewModel

Создаем синглтон AppRepository

public class AppRepository {

    private static AppRepository instance;

    public static AppRepository getInstance(Context context) {
        if (instance == null) instance = new AppRepository(context);
        return instance;
    }

    private AppRepository(Context context) {
    }
}

Далее создаем MainViewModel для фрагментов MainActivity

public class MainViewModel extends AndroidViewModel {

    private final AppRepository repository;

    public MainViewModel(@NonNull Application application) {
        super(application);
        repository = AppRepository.getInstance(application);
    }
}

Добавим класс Post с полям, конструктором и геттерами/сеттерами.

public class Post {

    private Uri image;
    private double latitude;
    private double longitude;

    private String header;
    private String description;
}

Добавим в AppRepository список постов, а также публичный метод, который возвращает этот список.

public class AppRepository {

    private List<Post> list;

    private static AppRepository instance;

    public static AppRepository getInstance(Context context) {
        if (instance == null) instance = new AppRepository(context);
        return instance;
    }

    private AppRepository(Context context) {
        list = new ArrayList<>();
        list.add(new Post(
                new Uri.Builder().scheme("res").path(String.valueOf(R.drawable.give_example)).build(),
                46.46177170826532, 30.746129175128416,
                "Header text",
                "Description"
        ));
    }

    public MutableLiveData<List<Post>> getList() {
        return new MutableLiveData<>(list);
    }
}

Далее модифицируем MainViewModel, добавляем метод getData(), который возвращает список данных из репозитория

public class MainViewModel extends AndroidViewModel {
    private final AppRepository repository;

    public MainViewModel(@NonNull Application application) {
        super(application);
        repository = AppRepository.getInstance(application);
    }

    public LiveData<List<Post>> getData() {
        return repository.getList();
    }
}

Далее подключаем ViewModel в фрагмент карты, а также добавляем показ маркера на карте

public class MapFragment extends Fragment {

    private MainViewModel model;
    private GoogleMap map;

    private OnMapReadyCallback callback = new OnMapReadyCallback() {

        @RequiresApi(api = Build.VERSION_CODES.N)
        @Override
        public void onMapReady(GoogleMap googleMap) {
            map = googleMap;
            map.getUiSettings().setCompassEnabled(false);
            model.getData().getValue().forEach(MapFragment.this::addNewMarker);
        }
    };

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater,
                             @Nullable ViewGroup container,
                             @Nullable Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_map, container, false);
    }

    @RequiresApi(api = Build.VERSION_CODES.N)
    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);

        model = ViewModelProviders.of(Objects.requireNonNull(getActivity())).get(MainViewModel.class);
        model.getData().observe(getViewLifecycleOwner(), posts -> {
            posts.forEach(this::addNewMarker);
        });

        SupportMapFragment mapFragment =
                (SupportMapFragment) getChildFragmentManager().findFragmentById(R.id.map);
        if (mapFragment != null) {
            mapFragment.getMapAsync(callback);
        }
    }

    public void addNewMarker(Post post) {
        MarkerOptions markerOption = new MarkerOptions()
                .position(new LatLng(post.getLatitude(), post.getLongitude()))
                .snippet(post.getDescription())
                .title(post.getHeader());

        if (map != null) {
            Marker marker = map.addMarker(markerOption);
        }
    }
}

Добавим еще один пост в репозиторий

private AppRepository(Context context) {
    list = new ArrayList<>();
    list.add(new Post(
            new Uri.Builder().scheme("res").path(String.valueOf(R.drawable.give_example)).build(),
            46.46177170826532, 30.746129175128416,
            "Header text1",
            "Description1"
    ));

    list.add(new Post(
            new Uri.Builder().scheme("res").path(String.valueOf(R.drawable.give_example)).build(),
            46.469084426946466, 30.737365481594992,
            "Header text2",
            "Description2"
    ));

Далее добавим методы в фрагменты, которые добавляют новый маркер, список маркеров и клик на маркер

public class MapFragment extends Fragment implements GoogleMap.OnMarkerClickListener {

    private MainViewModel model;
    private GoogleMap map;

    private HashMap<Marker, Post> markers = new HashMap<>();


    private OnMapReadyCallback callback = new OnMapReadyCallback() {

        @RequiresApi(api = Build.VERSION_CODES.N)
        @Override
        public void onMapReady(GoogleMap googleMap) {
            map = googleMap;
            map.setOnMarkerClickListener(MapFragment.this);
            map.getUiSettings().setCompassEnabled(false);
            invalidateMap();
        }
    };

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater,
                             @Nullable ViewGroup container,
                             @Nullable Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_map, container, false);
    }

    @RequiresApi(api = Build.VERSION_CODES.N)
    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);

        model = ViewModelProviders.of(Objects.requireNonNull(getActivity())).get(MainViewModel.class);
        model.getData().observe(getViewLifecycleOwner(), posts -> {

            if (map == null)
                return;

            invalidateMap();
        });

        SupportMapFragment mapFragment =
                (SupportMapFragment) getChildFragmentManager().findFragmentById(R.id.map);
        if (mapFragment != null) {
            mapFragment.getMapAsync(callback);
        }
    }

    private void invalidateMap() {
        clearMarkers();
        model.getData().getValue().forEach(MapFragment.this::addNewMarker);
        setActiveMarker(model.getData().getValue().get(0), true);
    }

    public void addNewMarker(Post post) {
        MarkerOptions markerOption = new MarkerOptions()
                .position(new LatLng(post.getLatitude(), post.getLongitude()))
                .snippet(post.getDescription())
                .icon(BitmapDescriptorFactory.fromResource(R.drawable.ic_map_marker_disabled))
                .title(post.getHeader());

        if (map != null) {
            Marker marker = map.addMarker(markerOption);
            markers.put(marker, post);
        }
    }

    public void clearMarkers() {
        if (map != null) {
            map.clear();
            markers.clear();
        }
    }

    @Override
    public boolean onMarkerClick(Marker marker) {
        for (Map.Entry<Marker, Post> entry : markers.entrySet()) {
            Marker m = entry.getKey();
            Post post = entry.getValue();

            if (m.getId().equals(marker.getId())) {
                setActiveMarker(post, true);
                return true;
            }
        }
        return false;
    }

    public void setActiveMarker(Post post, boolean initFromMarker) {
        for (Map.Entry<Marker, Post> entry : markers.entrySet()) {
            Post p = entry.getValue();
            Marker m = entry.getKey();

            if (p.getLatitude() == post.getLatitude() && p.getLongitude() == post.getLongitude()) {

                CameraUpdate center = CameraUpdateFactory.newLatLng(new LatLng(post.getLatitude(), post.getLongitude()));
                CameraUpdate new_zoom = CameraUpdateFactory.zoomTo(map.getCameraPosition().zoom);
                map.moveCamera(center);
                map.animateCamera(new_zoom);

                m.setIcon(BitmapDescriptorFactory.fromResource(R.drawable.ic_map_marker));
                m.setZIndex(Float.MAX_VALUE);
            } else {
                m.setIcon(BitmapDescriptorFactory.fromResource(R.drawable.ic_map_marker_disabled));
                m.setZIndex(0f);
            }
        }
    }
}

Last updated