Поиск по этому блогу

четверг, 30 ноября 2017 г.

Создание кастомного пина на Google map

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

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

image

У нас тут и Джордж Клуни, и Роберт Де Ниро и Дональд Трамп, прям отличная пьянка!


Начнем мы все с того же с чего я всегда начинаю статьи, — с настройки проекта, а это добавление библиотек в build.gradle. Использовать библиотеки всегда желательнее чем использовать самописные костыли, по этому старайтесь максимально чаще находить готовые библиотеки по каким-то задачам, будь то работа с сервером (Retofit), работа с базой данных (Realm или Room) или какая-то библиотека помогающая на пример с пермишенами в новых версиях андроида (Dexter).

app/build.gradle

android {
    ...
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'com.android.support:appcompat-v7:26.1.0'

    implementation 'de.hdodenhof:circleimageview:2.1.0'

    implementation 'com.jakewharton:butterknife:8.8.1'
    annotationProcessor 'com.jakewharton:butterknife-compiler:8.8.1'

    implementation 'io.reactivex.rxjava2:rxandroid:2.0.1'
    implementation 'io.reactivex.rxjava2:rxjava:2.1.5'

    implementation 'com.google.android.gms:play-services-maps:11.6.+'
}

И так, что же мы имеем у себя в app/build.gradle. У нас будет использоваться пара библиотек, а это — CircleImageView, ButterKnife, Google Map Services и RxAndroid. Последнюю я буду использовать чисто для красоты кода, не знаю как кому, а мне очень нравится как оно выглядит в проекте и всех настаиваю так же писать код. Так же у нас будет включена Java 8 для работы с лямбдой.

Теперь у нас готов плацдарм для работы с проектом. У нас создалась активити и леяут к ней, в ней мы будем создавать карту, подключается карта очень легко, в отличии от предыдущих версий когда требовалось кучу кода писать, теперь у нас достаточно создать на леяуте фрагмент с картой, и дальше подключить ее в активити, и все, у нас есть карта!

Как же будет выглядеть MainActivity с картой. Для начала нам нужно добавить фрагмент с картой в леяут, сделаем это.

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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"
    tools:context="project.dajver.com.roundpinwithavatarexample.MainActivity">

    <fragment 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"
        android:id="@+id/map"
        tools:context=".MapsActivity"
        android:name="com.google.android.gms.maps.SupportMapFragment" />

</LinearLayout>

Супер, у нас есть карта на экране активити, теперь нам нужно подключить и заставить ее работать в самом классе. Для этого нам нужно создать объект SupportMapFragment и подключить к нему наш фрагмент map.

MainActivity.java
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;

import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.OnMapReadyCallback;
import com.google.android.gms.maps.SupportMapFragment;
import com.google.android.gms.maps.model.BitmapDescriptorFactory;
import com.google.android.gms.maps.model.MarkerOptions;

import java.util.ArrayList;

import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
import project.dajver.com.roundpinwithavatarexample.view.model.PinsModel;
import project.dajver.com.roundpinwithavatarexample.view.repo.RepositoryImpl;

public class MainActivity extends AppCompatActivity implements OnMapReadyCallback {

    private GoogleMap mMap;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        SupportMapFragment mapFragment = (SupportMapFragment) getSupportFragmentManager().findFragmentById(R.id.map);
        mapFragment.getMapAsync(this);
    }

    @Override
    public void onMapReady(GoogleMap googleMap) {
        
    }
}

Как видно из кода у нас есть объект GoogleMap который мы в дальнейшем будем использовать как метку для добавления пинов на карту, перемещение камеры по карте и так далее. И есть объект SupportMapFragment который собственно и есть карта, и к нему у нас подключен колбек getMapAsync который по готовности карты вернет нам что карта готова к работе, и дальше мы будем готовы устанавливать разные пины на карту и т.д. Так же для того что бы карта работала нам нужно добавить API Key для работы, его можно зарегистрировать тут. И после того как сгенерируете код для карты, нужно будет его указать в string.xml.

string.xml
    <string name="google_api_key_map">AIzaSyBcCGUs7wKBzZJApPY-jAiDjkssHgz9UD94</string>

Собственно дальше нам нужно создать класс который будет описывать создание пина из картинки и определенного шаблона. У нас будет три вида пинов, красный, желтый и зеленый, я это сделал чисто для примера, а так их можно сделать хоть 150 видов, но у нас их будет три. Для начала нам нужно создать вьюху которая будет шаблоном для пина. У нас так же будут использоваться три картинки для них и одна заглушка если нету картинки пользователя.

image image image image

view_custom_map_pin.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/custom_marker_view"
    android:layout_width="72dp"
    android:layout_height="93dp"
    android:background="@mipmap/red_map_pin">

    <de.hdodenhof.circleimageview.CircleImageView
        android:id="@+id/profile_image"
        android:layout_width="60dp"
        android:layout_height="60dp"
        android:layout_centerHorizontal="true"
        android:layout_gravity="center_horizontal"
        android:layout_marginTop="6dp"
        android:contentDescription="@null"
        android:scaleType="centerCrop"
        android:src="@mipmap/user_profile_image" />

    <TextView
        android:id="@+id/userName"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignEnd="@+id/profile_image"
        android:layout_alignRight="@+id/profile_image"
        android:layout_alignTop="@+id/profile_image"
        android:layout_marginLeft="5dp"
        android:layout_marginRight="5dp"
        android:layout_marginTop="20dp"
        android:singleLine="true"
        android:text="TextView"
        android:textColor="@color/white"
        android:visibility="gone" />
</RelativeLayout>

В ней у нас RelativeLayout у которого наложен фон в виде одной из картинок для пина, так же внутри у нас ImageView скругленный для аватара, и TextView под ним для того что бы если у нас нет аватара то отображалось хотя бы имя, по умолчанию он не видимый.

Так же у нас используются цвета, белый, красный, зеленый и желтый, их я указал в colors.xml файле.

colors.xml
    <color name="yellow">#f5dc4f</color>
    <color name="red">#e74a4a</color>
    <color name="green">#1aa36d</color>
    <color name="white">#fff</color>

Ну а теперь напишем что бы это все работало так как нам нужно.

CustomPinView.java
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.support.annotation.AttrRes;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.RelativeLayout;
import android.widget.TextView;

import butterknife.BindView;
import butterknife.ButterKnife;
import de.hdodenhof.circleimageview.CircleImageView;
import project.dajver.com.roundpinwithavatarexample.R;

public class CustomPinView extends FrameLayout {

    @BindView(R.id.profile_image)
    CircleImageView markerImageView;
    @BindView(R.id.custom_marker_view)
    RelativeLayout customMarkerView;
    @BindView(R.id.userName)
    TextView userName;

    private Context context;

    public CustomPinView(@NonNull Context context) {
        super(context);
        init(context);
    }

    public CustomPinView(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }

    public CustomPinView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    private void init(Context context) {
        this.context = context;
        inflate(context, R.layout.view_custom_map_pin, this);
        ButterKnife.bind(this);
    }

    public void setBackground(int id) {
        customMarkerView.setBackgroundResource(id);
    }

    public void setIcon(Bitmap bmpImg, String name, String mapStatus) {
        if(bmpImg != null)
            markerImageView.setImageBitmap(bmpImg);
        else {
            Rect rect = new Rect(0, 0, 1, 1);
            Bitmap image = Bitmap.createBitmap(rect.width(), rect.height(), Bitmap.Config.ARGB_8888);
            Canvas canvas = new Canvas(image);
            int color = Color.RED;
            if(mapStatus.equals("one"))
                color = getResources().getColor(R.color.yellow);
            else if(mapStatus.equals("two"))
                color = getResources().getColor(R.color.green);
            else
                color = getResources().getColor(R.color.red);
            Paint paint = new Paint();
            paint.setColor(color);
            canvas.drawRect(rect, paint);
            markerImageView.setImageBitmap(image);

            userName.setVisibility(View.VISIBLE);
            userName.setText(name);
        }
    }
}

Что мы тут видим? В методе init() инициализируем нашу вьюху, ButterKnife и дальше у нас идут методы сеттеры для имени и аватара. Что в имени то понятно, просто задаем имя, а вот в аватаре мы создаем на основе битмапа который нам передаст метод скачивающий фотку с интернета и присваиваем его в ImageView или же если битмап пустой то делаем картинку на основе статуса который у нас передается в mapStatus, и ставим в зависимости от статуса тот или иной цвет на фон и указываем имя пользователя.

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

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

IRepository.java
import io.reactivex.Observable;
import project.dajver.com.roundpinwithavatarexample.view.repo.model.PinsModel;
import project.dajver.com.roundpinwithavatarexample.view.repo.model.ResponseModel;

public interface IRepository {
    Observable<ResponseModel> getImages(String url, PinsModel model);
}

Вот мы создали интерфейс, в котором создали метод возвращающий нам ResponseModel в котором у нас содержится Bitmap и LatLng. Вот его структура.

ResponseModel.java
import android.graphics.Bitmap;

import com.google.android.gms.maps.model.LatLng;

public class ResponseModel {

    private Bitmap image;
    private LatLng latLng;

    public ResponseModel(Bitmap image, LatLng latLng) {
        this.image = image;
        this.latLng = latLng;
    }

    public Bitmap getImage() {
        return image;
    }

    public void setImage(Bitmap image) {
        this.image = image;
    }

    public LatLng getLatLng() {
        return latLng;
    }

    public void setLatLng(LatLng latLng) {
        this.latLng = latLng;
    }
}

В него мы передадим все параметры когда получим в итоге, и дальше из него же будем доставать эти данные для отображения на карте. А еще в метод getImages() мы передаем ссылку и PinsModel в котором у нас содержится вся информация по пину, айди, имя, картинка, и статус карты (цвет пина). 

PinsModel.java
public class PinsModel {

    private Integer id;
    private String fullName;
    private String avatarUrl;
    private String mapStatus;

    public PinsModel(Integer id, String fullName, String avatarUrl, String mapStatus) {
        this.id = id;
        this.fullName = fullName;
        this.avatarUrl = avatarUrl;
        this.mapStatus = mapStatus;
    }

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getFullName() {
        return fullName;
    }

    public void setFullName(String fullName) {
        this.fullName = fullName;
    }

    public String getAvatarUrl() {
        return avatarUrl;
    }

    public void setAvatarUrl(String avatarUrl) {
        this.avatarUrl = avatarUrl;
    }

    public String getMapStatus() {
        return mapStatus;
    }

    public void setMapStatus(String mapStatus) {
        this.mapStatus = mapStatus;
    }
}

Дальше нам нужно создать реализацию интерфейса IRepository, и написать там метод для скачивания картинок с интернета.

RepositoryImpl.java
import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.util.DisplayMetrics;
import android.view.View;
import android.view.ViewGroup;

import com.google.android.gms.maps.model.LatLng;

import java.io.InputStream;
import java.util.Random;

import io.reactivex.Observable;
import project.dajver.com.roundpinwithavatarexample.R;
import project.dajver.com.roundpinwithavatarexample.view.CustomPinView;
import project.dajver.com.roundpinwithavatarexample.view.repo.model.PinsModel;
import project.dajver.com.roundpinwithavatarexample.view.repo.model.ResponseModel;

public class RepositoryImpl implements IRepository {

    private Context context;

    public RepositoryImpl(Context context) {
        this.context = context;
    }

    @Override
    public Observable<ResponseModel> getImages(String url, PinsModel pinsModel) {
        return Observable.create(observableEmitter -> {
            try {
                Bitmap bmpImg = null;
                try {
                    InputStream in = new java.net.URL(url).openStream();
                    bmpImg = BitmapFactory.decodeStream(in);
                } catch (Exception e) {
                    e.printStackTrace();
                }

                CustomPinView customPinView = new CustomPinView(context);
                if(pinsModel.getMapStatus().equals("one"))
                    customPinView.setBackground(R.mipmap.yelow_map_pin);
                else if(pinsModel.getMapStatus().equals("two"))
                    customPinView.setBackground(R.mipmap.green_map_pin);
                else
                    customPinView.setBackground(R.mipmap.red_map_pin);
                customPinView.setIcon(bmpImg, pinsModel.getFullName(), pinsModel.getMapStatus());

                int rand = new Random().nextInt(151 - 14) + 14;
                LatLng randLatLng = new LatLng(-rand, rand);

                ResponseModel responseModel = new ResponseModel(createDrawableFromView(context, customPinView), randLatLng);
                observableEmitter.onNext(responseModel);
            } catch (Exception e) {
                observableEmitter.onError(e);
            } finally {
                observableEmitter.onComplete();
            }
        });
    }

    public Bitmap createDrawableFromView(Context context, View view) {
        DisplayMetrics displayMetrics = new DisplayMetrics();
        ((Activity) context).getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
        view.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
        view.measure(displayMetrics.widthPixels, displayMetrics.heightPixels);
        view.layout(0, 0, displayMetrics.widthPixels, displayMetrics.heightPixels);
        view.buildDrawingCache();
        Bitmap bitmap = Bitmap.createBitmap(view.getMeasuredWidth(), view.getMeasuredHeight(), Bitmap.Config.ARGB_8888);

        Canvas canvas = new Canvas(bitmap);
        view.draw(canvas);

        return bitmap;
    }
}

После того как мы заимплементировали IRepository в этот класс, нам предложит студия добавить обязательные методы, и после того как вы добавите их оно создаст наш метод из интерфейса в который мы написали реализацию скачивания и в дальнейшем преобразование в зависимости от статуса пин в наш кастомный. Метод createDrawableFromView() позволяет нам преобразовать наш пин в доступную для ImageView версию для отображения, по этому мы из пина делаем Bitmap. Да кстати хочу заметить что локейшн я сделал рандомный, так что пины будут распологаться рандомно на карте…

Ну а дальше осталось только вернуться в MainActivity и прописать создание пинов на карте. Для этого смотрим в метод onMapReady() который у нас на данный момент пустой, но буквально через секунду мы его заполним.

MainActivity.java
@Override
    public void onMapReady(GoogleMap googleMap) {
        mMap = googleMap;

        ArrayList<PinsModel> pinsModels = new ArrayList<>();
        pinsModels.add(new PinsModel(0, "George Clooney", "https://petapixel.com/assets/uploads/2012/04/famous1_mini.jpg", "one"));
        pinsModels.add(new PinsModel(1, "Donald Trump", "http://www.samhurdphotography.com/wp-content/uploads/2014/06/dc-celebrity-portrait-photographers.jpg", "two"));
        pinsModels.add(new PinsModel(2, "Robert De Niro", "https://www.thefamouspeople.com/profiles/images/robert-de-niro-3.jpg", "three"));
        for (int i = 0; i < pinsModels.size(); i++) {
            new RepositoryImpl(this).getImages(pinsModels.get(i).getAvatarUrl(), pinsModels.get(i))
                    .subscribeOn(Schedulers.io())
                    .observeOn(AndroidSchedulers.mainThread())
                    .subscribe(responseModel -> {
                        mMap.addMarker(new MarkerOptions()
                                .position(responseModel.getLatLng())
                                .icon(BitmapDescriptorFactory.fromBitmap(responseModel.getImage())));
                    });
        }
    }

В самом начале метода мы присваиваем mMap его инстанс который вернул нам колбек, что бы дальше можно было работать с ним для добавления разных фишек на карту. Создаем список с нужными нам картинками и личностями, в моем случае их три, и потом в цикле я их отображаю скачивая их фотографии с помощью нашего класса RepositoryImpl. И в результате добавляю их на карту с кастомным локейшеном и кастомной картинкой.

Ну и осталось в манифесте прописать пермишен для работы с интернетом и мета данные для работы карты.

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="project.dajver.com.roundpinwithavatarexample">

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

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <meta-data
            android:name="com.google.android.geo.API_KEY"
            android:value="@string/google_api_key_map"/>
    </application>

</manifest>

Как то так. Дальше можно компилировать. Если нет ошибок это замечательно, если есть то проверяем еще раз и смотрим не забыли ли мы что-то добавить.

Исходники:
GitHub