├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── exlyo │ │ └── gmfmtexample │ │ ├── MapsActivity.java │ │ └── SampleMarkerData.java │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ └── ic_launcher_background.xml │ ├── layout │ └── activity_maps.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ └── values │ ├── colors.xml │ ├── strings.xml │ └── styles.xml ├── build.gradle ├── googlemapsfloatingmarkertitles ├── .gitignore ├── build.gradle └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── com │ └── exlyo │ └── gmfmt │ ├── FloatingMarkerTitlesOverlay.java │ ├── GMFMTGeometryCache.java │ ├── GMFMTUtils.java │ └── MarkerInfo.java ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── visual_demo.gif /.gitignore: -------------------------------------------------------------------------------- 1 | /googlemapsfloatingmarkertitles/googlemapsfloatingmarkertitles.iml 2 | /android-gmfmt.iml 3 | /app/app.iml 4 | /local.properties 5 | .idea 6 | .gradle 7 | 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 androidseb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android Google Maps Floating Marker Titles 2 | 3 | This library is useful if you want to see the titles of the markers on the map floating next to the marker. It attempts to reproduce the behavior for points of interest shown on the map in Google Maps. Since there is no way to draw directly into the map component, it works as a view to apply on top of the map as an overlay. 4 | 5 | I initially created this library for [the Android version of Map Marker](https://play.google.com/store/apps/details?id=com.exlyo.mapmarker) when it was still using Android native code, I've since then moved to Flutter. You can still download the APK of the last version of Map Marker still using this library [here](https://github.com/androidseb/mapmarker/releases/tag/map_marker_v2). 6 | 7 | **Note:** If you're looking for a cross-platform implementation, I've created the [flutter_map_floating_marker_titles](https://github.com/androidseb/flutter_map_floating_marker_titles) library for Flutter. 8 | 9 | ## Visual Preview 10 | 11 | ![](./visual_demo.gif) 12 | 13 | ## Project structure 14 | 15 | The library project is the `googlemapsfloatingmarkertitles` folder. The root of the repository is also an Android studio project with a sample app's code. 16 | 17 | ## Sample app setup 18 | 19 | To make the sample app work, you will need to update the Android manifest file `app/src/main/AndroidManifest.xml` and update this section with your Google Maps API key: 20 | ```xml 21 | 24 | ``` 25 | 26 | ## How to use this library in your code 27 | 28 | 1. You will have to add the library folder manually to your Android project 29 | 2. Add a FloatingMarkerTitlesOverlay view on top of your map view in your XML layout 30 | 3. In your code, retrieve the FloatingMarkerTitlesOverlay view and initialize it like this 31 | ```java 32 | private FloatingMarkerTitlesOverlay floatingMarkersOverlay; 33 | @Override 34 | public void onMapReady(GoogleMap googleMap) { 35 | //Code related to initializing googleMap related attributes 36 | 37 | //Initializing FloatingMarkerTitlesOverlay 38 | floatingMarkersOverlay = findViewById(R.id.map_floating_markers_overlay); 39 | floatingMarkersOverlay.setSource(googleMap); 40 | 41 | //From now on, you can add markers to be tracked by floatingMarkersOverlay 42 | } 43 | ``` 44 | 4. Add a marker to the map 45 | ```java 46 | final long id = 0; 47 | final LatLng latLng = new LatLng(-34, 151); 48 | final String title = "A cool marker"; 49 | final int color = Color.GREEN; 50 | final MarkerInfo mi = new MarkerInfo(latLng, title, color); 51 | googleMap.addMarker(new MarkerOptions().position(mi.getCoordinates())); 52 | //Adding the marker to track by the overlay 53 | //To remove that marker, you will need to call floatingMarkersOverlay.removeMarker(id) 54 | floatingMarkersOverlay.addMarker(id, mi); 55 | ``` 56 | 57 | ## Main library features 58 | 59 | - Display floating markers titles on top of the map 60 | - Works with ancient versions of Android: minSdkVersion 9 61 | - Automatically avoids overlap between floating marker titles, will not display a title if overlapping with others 62 | - Set z-indexes for floating marker titles to specify which title has the most priority for display: MarkerInfo.setZIndex(...) 63 | - Set whether floating marker titles should be written in bold: MarkerInfo.setBoldText(...) 64 | - Marker title text transparent outline for better visuals: the text will be readable no matter the map background and the outline color will adapt to white or black depending on the text color's luminance (perceived brightness) 65 | - Marker title fade-in animation for better visuals 66 | - Set the text size: FloatingMarkerTitlesOverlay.setTextSizeDIP(...) 67 | - Set the distance between the text and the marker center: FloatingMarkerTitlesOverlay.setTextPaddingToMarkerDIP(...) 68 | - Set the maximum number of floating titles: FloatingMarkerTitlesOverlay.setMaxFloatingTitlesCount(...) 69 | - No performance drop with more markers once the maximum number of floating titles has been reached, since the library only scans for a limited number of markers per frame, which can be set with FloatingMarkerTitlesOverlay.setSetMaxNewMarkersCheckPerFrame(...) 70 | - Set the maximum width of floating titles: FloatingMarkerTitlesOverlay.setMaxTextWidthDIP(...) 71 | - Set the maximum height of floating titles: FloatingMarkerTitlesOverlay.setMaxTextHeightDIP(...) 72 | 73 | 74 | ## About issues and/or feature requests 75 | 76 | I am not willing to invest time to take feature requests at the moment since this library has sufficient features for my needs as is. 77 | If you find a bug, I'll probably want to fix it since it might affect my production app, so feel free to report any bugs you may find. 78 | If you need a feature and want to build it and then submit it as a pull request, I'm willing to work with you to merge your work into the current code. 79 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | android { 4 | compileSdkVersion 26 5 | defaultConfig { 6 | applicationId "com.exlyo.gmfmtexample" 7 | minSdkVersion 14 8 | targetSdkVersion 26 9 | versionCode 1 10 | versionName "1.0" 11 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 12 | } 13 | buildTypes { 14 | release { 15 | minifyEnabled false 16 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 17 | } 18 | } 19 | } 20 | 21 | dependencies { 22 | implementation fileTree(dir: 'libs', include: ['*.jar']) 23 | implementation 'com.android.support:appcompat-v7:26.1.0' 24 | implementation 'com.google.android.gms:play-services-maps:15.0.1' 25 | implementation project(':googlemapsfloatingmarkertitles') 26 | testImplementation 'junit:junit:4.12' 27 | androidTestImplementation 'com.android.support.test:runner:1.0.2' 28 | androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' 29 | } 30 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 9 | 10 | 11 | 18 | 21 | 22 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/exlyo/gmfmtexample/MapsActivity.java: -------------------------------------------------------------------------------- 1 | package com.exlyo.gmfmtexample; 2 | 3 | import android.graphics.Color; 4 | import android.support.v4.app.FragmentActivity; 5 | import android.os.Bundle; 6 | import android.view.View; 7 | 8 | import com.exlyo.gmfmt.FloatingMarkerTitlesOverlay; 9 | import com.exlyo.gmfmt.MarkerInfo; 10 | import com.google.android.gms.maps.CameraUpdateFactory; 11 | import com.google.android.gms.maps.GoogleMap; 12 | import com.google.android.gms.maps.OnMapReadyCallback; 13 | import com.google.android.gms.maps.SupportMapFragment; 14 | import com.google.android.gms.maps.model.LatLng; 15 | import com.google.android.gms.maps.model.Marker; 16 | import com.google.android.gms.maps.model.MarkerOptions; 17 | 18 | import java.util.List; 19 | 20 | public class MapsActivity extends FragmentActivity implements OnMapReadyCallback { 21 | 22 | private GoogleMap mMap; 23 | 24 | @Override 25 | protected void onCreate(Bundle savedInstanceState) { 26 | super.onCreate(savedInstanceState); 27 | setContentView(R.layout.activity_maps); 28 | // Obtain the SupportMapFragment and get notified when the map is ready to be used. 29 | SupportMapFragment mapFragment = (SupportMapFragment) getSupportFragmentManager() 30 | .findFragmentById(R.id.map); 31 | mapFragment.getMapAsync(this); 32 | } 33 | 34 | 35 | /** 36 | * Manipulates the map once available. 37 | * This callback is triggered when the map is ready to be used. 38 | * This is where we can add markers or lines, add listeners or move the camera. In this case, 39 | * we just add a marker near Sydney, Australia. 40 | * If Google Play services is not installed on the device, the user will be prompted to install 41 | * it inside the SupportMapFragment. This method will only be triggered once the user has 42 | * installed Google Play services and returned to the app. 43 | */ 44 | @Override 45 | public void onMapReady(GoogleMap googleMap) { 46 | mMap = googleMap; 47 | 48 | final FloatingMarkerTitlesOverlay floatingMarkersOverlay = findViewById(R.id.map_floating_markers_overlay); 49 | floatingMarkersOverlay.setSource(googleMap); 50 | 51 | final List markerInfoList = SampleMarkerData.getSampleMarkersInfo(); 52 | for (int i = 0; i < markerInfoList.size(); i++) { 53 | final MarkerInfo mi = markerInfoList.get(i); 54 | mMap.addMarker(new MarkerOptions().position(mi.getCoordinates())); 55 | floatingMarkersOverlay.addMarker(i, mi); 56 | } 57 | mMap.moveCamera(CameraUpdateFactory.newLatLng(markerInfoList.get(0).getCoordinates())); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/src/main/java/com/exlyo/gmfmtexample/SampleMarkerData.java: -------------------------------------------------------------------------------- 1 | package com.exlyo.gmfmtexample; 2 | 3 | import com.exlyo.gmfmt.MarkerInfo; 4 | import com.google.android.gms.maps.model.LatLng; 5 | 6 | import java.util.ArrayList; 7 | import java.util.List; 8 | 9 | public class SampleMarkerData { 10 | private static final int MARKER_COUNT_SQRT = 20; 11 | 12 | private static final double BASE_LAT = -34; 13 | 14 | private static final double BASE_LNG = 151; 15 | 16 | private static String[] MARKER_TITLES = { 17 | "A", 18 | "A marker", 19 | "And another marker", 20 | "Wow", 21 | "This marker has a long title", 22 | "This marker has a very very very very very very very very very long title", 23 | "This marker has a very very very very very very very very very very very very very very very very very very long title", 24 | "This marker has a very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very long title", 25 | "Restaurant", 26 | "Bakery", 27 | "Shop", 28 | "And another example", 29 | }; 30 | 31 | private static int[] MARKER_COLORS = { 32 | 0xFFf44336, 33 | 0xFFe91e63, 34 | 0xFF9c27b0, 35 | 0xFF673ab7, 36 | 0xFF3f51b5, 37 | 0xFF2196f3, 38 | 0xFF03a9f4, 39 | 0xFF00bcd4, 40 | 0xFF009688, 41 | 0xFF71B300, 42 | 0xFF8bc34a, 43 | 0xFFcddc39, 44 | 0xFFffeb3b, 45 | 0xFFffc107, 46 | 0xFFff9800, 47 | 0xFFff5722, 48 | 0xFF795548, 49 | 0xFF9e9e9e, 50 | 0xFF607d8b, 51 | }; 52 | 53 | public static List getSampleMarkersInfo() { 54 | final ArrayList res = new ArrayList<>(); 55 | int counter = 0; 56 | for (int i = 0; i < MARKER_COUNT_SQRT; i++) { 57 | for (int j = 0; j < MARKER_COUNT_SQRT; j++) { 58 | final LatLng latLng = new LatLng(// 59 | BASE_LAT + (double) i / (double) MARKER_COUNT_SQRT,// 60 | BASE_LNG + (double) j / (double) MARKER_COUNT_SQRT// 61 | ); 62 | final String title = MARKER_TITLES[counter % MARKER_TITLES.length]; 63 | final int color = MARKER_COLORS[counter % MARKER_COLORS.length]; 64 | res.add(new MarkerInfo(latLng, title, color)); 65 | counter++; 66 | } 67 | } 68 | return res; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_maps.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 13 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/androidseb/android-google-maps-floating-marker-titles/d5b56cac1baccf2b03c10f50488f7761a9fdb5eb/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/androidseb/android-google-maps-floating-marker-titles/d5b56cac1baccf2b03c10f50488f7761a9fdb5eb/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/androidseb/android-google-maps-floating-marker-titles/d5b56cac1baccf2b03c10f50488f7761a9fdb5eb/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/androidseb/android-google-maps-floating-marker-titles/d5b56cac1baccf2b03c10f50488f7761a9fdb5eb/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/androidseb/android-google-maps-floating-marker-titles/d5b56cac1baccf2b03c10f50488f7761a9fdb5eb/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/androidseb/android-google-maps-floating-marker-titles/d5b56cac1baccf2b03c10f50488f7761a9fdb5eb/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/androidseb/android-google-maps-floating-marker-titles/d5b56cac1baccf2b03c10f50488f7761a9fdb5eb/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/androidseb/android-google-maps-floating-marker-titles/d5b56cac1baccf2b03c10f50488f7761a9fdb5eb/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/androidseb/android-google-maps-floating-marker-titles/d5b56cac1baccf2b03c10f50488f7761a9fdb5eb/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/androidseb/android-google-maps-floating-marker-titles/d5b56cac1baccf2b03c10f50488f7761a9fdb5eb/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3F51B5 4 | #303F9F 5 | #FF4081 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | GMFMT Example 3 | GMFMT Example 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | 5 | repositories { 6 | google() 7 | jcenter() 8 | } 9 | dependencies { 10 | classpath 'com.android.tools.build:gradle:3.1.2' 11 | 12 | 13 | // NOTE: Do not place your application dependencies here; they belong 14 | // in the individual module build.gradle files 15 | } 16 | } 17 | 18 | allprojects { 19 | repositories { 20 | google() 21 | jcenter() 22 | } 23 | } 24 | 25 | task clean(type: Delete) { 26 | delete rootProject.buildDir 27 | } 28 | -------------------------------------------------------------------------------- /googlemapsfloatingmarkertitles/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /googlemapsfloatingmarkertitles/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | 3 | android { 4 | compileSdkVersion 26 5 | 6 | defaultConfig { 7 | minSdkVersion 9 8 | targetSdkVersion 26 9 | versionCode 1 10 | versionName "1.0" 11 | } 12 | buildTypes { 13 | release { 14 | minifyEnabled false 15 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 16 | } 17 | } 18 | } 19 | 20 | dependencies { 21 | implementation 'com.google.android.gms:play-services-maps:15.0.1' 22 | } 23 | -------------------------------------------------------------------------------- /googlemapsfloatingmarkertitles/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /googlemapsfloatingmarkertitles/src/main/java/com/exlyo/gmfmt/FloatingMarkerTitlesOverlay.java: -------------------------------------------------------------------------------- 1 | package com.exlyo.gmfmt; 2 | 3 | import android.content.Context; 4 | import android.graphics.Canvas; 5 | import android.graphics.Color; 6 | import android.graphics.Paint; 7 | import android.graphics.Point; 8 | import android.graphics.RectF; 9 | import android.graphics.Typeface; 10 | import android.support.annotation.NonNull; 11 | import android.support.annotation.Nullable; 12 | import android.text.TextPaint; 13 | import android.util.AttributeSet; 14 | import android.view.View; 15 | 16 | import com.google.android.gms.maps.GoogleMap; 17 | 18 | import java.util.ArrayList; 19 | import java.util.HashMap; 20 | import java.util.List; 21 | import java.util.Map; 22 | 23 | /** 24 | * This view is meant to be overlayed on top of a map with the exact same dimensions as the map. 25 | * It will attempt to redraw all the time to keep the marker floating titles up to date with the map below. 26 | */ 27 | public class FloatingMarkerTitlesOverlay extends View { 28 | /* The fade in animation time for text appearing */ 29 | private static final long FADE_ANIMATION_TIME = 300; 30 | 31 | @Nullable 32 | private GMFMTGeometryCache geometryCache; 33 | 34 | @NonNull 35 | private final Map markerIdToMarkerInfoMap = new HashMap<>(); 36 | 37 | @NonNull 38 | private final List markerInfoList = new ArrayList<>(); 39 | 40 | /* List of markers that are currently displayed as floating text */ 41 | @NonNull 42 | private final List displayedMarkersList = new ArrayList<>(); 43 | 44 | /* Map of displayed MarkerInfo to the rectangle their floating text is taking on the screen */ 45 | @NonNull 46 | private final Map displayedMarkerIdToScreenRect = new HashMap<>(); 47 | 48 | /* Map of displayed MarkerInfo to time they were added, to properly calculate the animation state (text alpha) */ 49 | @NonNull 50 | private final Map displayedMarkerIdToAddedTime = new HashMap<>(); 51 | 52 | float textPaddingToMarker; 53 | 54 | private int maxFloatingTitlesCount; 55 | 56 | private int maxNewMarkersCheckPerFrame; 57 | 58 | float maxTextWidth; 59 | 60 | float maxTextHeight; 61 | 62 | TextPaint regularTextPaint; 63 | TextPaint boldTextPaint; 64 | 65 | public FloatingMarkerTitlesOverlay(final Context context) { 66 | super(context); 67 | initFMTOverlay(); 68 | } 69 | 70 | public FloatingMarkerTitlesOverlay(final Context context, @Nullable final AttributeSet attrs) { 71 | super(context, attrs); 72 | initFMTOverlay(); 73 | } 74 | 75 | public FloatingMarkerTitlesOverlay(final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { 76 | super(context, attrs, defStyleAttr); 77 | initFMTOverlay(); 78 | } 79 | 80 | private void initFMTOverlay() { 81 | regularTextPaint = new TextPaint(); 82 | regularTextPaint.setFlags(Paint.ANTI_ALIAS_FLAG); 83 | regularTextPaint.setStrokeWidth(GMFMTUtils.dipToPixels(getContext(), 3)); 84 | boldTextPaint = new TextPaint(); 85 | boldTextPaint.setFlags(Paint.ANTI_ALIAS_FLAG); 86 | boldTextPaint.setStrokeWidth(GMFMTUtils.dipToPixels(getContext(), 3)); 87 | boldTextPaint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)); 88 | 89 | setTextSizeDIP(14); 90 | setTextPaddingToMarkerDIP(8); 91 | setMaxFloatingTitlesCount(100); 92 | setSetMaxNewMarkersCheckPerFrame(10); 93 | setMaxTextWidthDIP(200); 94 | setMaxTextHeightDIP(48); 95 | } 96 | 97 | public void setTextSizeDIP(final int _textSizeDIP) { 98 | regularTextPaint.setTextSize(GMFMTUtils.dipToPixels(getContext(), _textSizeDIP)); 99 | boldTextPaint.setTextSize(GMFMTUtils.dipToPixels(getContext(), _textSizeDIP)); 100 | } 101 | 102 | /** 103 | * Set the spacing between the marker location and the floating text 104 | */ 105 | public void setTextPaddingToMarkerDIP(final int _textPaddingToMarkerDIP) { 106 | textPaddingToMarker = GMFMTUtils.dipToPixels(getContext(), _textPaddingToMarkerDIP); 107 | } 108 | 109 | /** 110 | * Set the maximum number of floating titles displayed at the same time 111 | */ 112 | public void setMaxFloatingTitlesCount(final int _maxFloatingTitlesCount) { 113 | synchronized (markerInfoList) { 114 | maxFloatingTitlesCount = _maxFloatingTitlesCount; 115 | displayedMarkersList.clear(); 116 | displayedMarkerIdToScreenRect.clear(); 117 | displayedMarkerIdToAddedTime.clear(); 118 | } 119 | postInvalidate(); 120 | } 121 | 122 | /** 123 | * Set the maximum number of checks for new markers every display frame. Raising the value will decrease performance for maps with a 124 | * lot of markers, but increase responsiveness when a marker's title should appear. The default value is 10. 125 | *

126 | * For example, if you set this value to 50 and you have 2000 markers, it might take up to 2000/50 = 40 frames before a specific 127 | * marker's title to appear when it should display. Assuming you're having 60 frames per second, it will take about 0.66 seconds. 128 | */ 129 | public void setSetMaxNewMarkersCheckPerFrame(final int _setMaxNewMarkersCheckPerFrame) { 130 | maxNewMarkersCheckPerFrame = _setMaxNewMarkersCheckPerFrame; 131 | } 132 | 133 | public void setMaxTextWidthDIP(final int _maxTextWidthDIP) { 134 | maxTextWidth = GMFMTUtils.dipToPixels(getContext(), _maxTextWidthDIP); 135 | } 136 | 137 | public void setMaxTextHeightDIP(final int _maxTextHeightDIP) { 138 | maxTextHeight = GMFMTUtils.dipToPixels(getContext(), _maxTextHeightDIP); 139 | } 140 | 141 | public void setSource(@Nullable final GoogleMap _googleMap) { 142 | if (_googleMap == null) { 143 | clearMarkers(); 144 | geometryCache = null; 145 | } else { 146 | geometryCache = new GMFMTGeometryCache(this, _googleMap); 147 | } 148 | } 149 | 150 | /** 151 | * Removes all the tracked markers from the overlay. 152 | */ 153 | public void clearMarkers() { 154 | synchronized (markerInfoList) { 155 | markerIdToMarkerInfoMap.clear(); 156 | markerInfoList.clear(); 157 | displayedMarkersList.clear(); 158 | displayedMarkerIdToScreenRect.clear(); 159 | displayedMarkerIdToAddedTime.clear(); 160 | } 161 | } 162 | 163 | /** 164 | * Adds a marker to track with the overlay. 165 | * 166 | * @param _id: ID to track the marker for further removal 167 | * @param _markerInfo: MarkerInfo object containing the info of the marker 168 | */ 169 | public void addMarker(final long _id, @NonNull final MarkerInfo _markerInfo) { 170 | synchronized (markerInfoList) { 171 | markerIdToMarkerInfoMap.put(_id, _markerInfo); 172 | markerInfoList.add(_markerInfo); 173 | } 174 | } 175 | 176 | /** 177 | * Removes a marker from the overlay by ID. 178 | * 179 | * @param _id: ID of the marker to remove from the overlay 180 | */ 181 | public void removeMarker(final long _id) { 182 | synchronized (markerInfoList) { 183 | final MarkerInfo markerInfo = markerIdToMarkerInfoMap.get(_id); 184 | if (markerInfo != null) { 185 | markerInfoList.remove(markerInfo); 186 | displayedMarkersList.remove(markerInfo); 187 | displayedMarkerIdToScreenRect.remove(markerInfo); 188 | displayedMarkerIdToAddedTime.remove(markerInfo); 189 | } 190 | } 191 | } 192 | 193 | @Override 194 | public void draw(final Canvas _canvas) { 195 | super.draw(_canvas); 196 | if (maxFloatingTitlesCount == 0) { 197 | return; 198 | } 199 | final GMFMTGeometryCache gc = geometryCache; 200 | if (_canvas == null || gc == null) { 201 | return; 202 | } 203 | synchronized (markerInfoList) { 204 | drawFloatingMarkerTitles(_canvas, gc); 205 | } 206 | postInvalidate(); 207 | } 208 | 209 | private void drawFloatingMarkerTitles(@NonNull final Canvas _canvas, @NonNull final GMFMTGeometryCache _geometryCache) { 210 | _geometryCache.prepareForNewFrame(_canvas); 211 | updateCurrentlyDisplayedMarkers(_geometryCache); 212 | for (final MarkerInfo mi : displayedMarkersList) { 213 | drawMarkerFloatingTitle(_canvas, mi); 214 | } 215 | } 216 | 217 | private void updateCurrentlyDisplayedMarkers(@NonNull final GMFMTGeometryCache _geometryCache) { 218 | // Remove the currently displayed markers that are no longer in the view bounds 219 | removeOutOfViewMarkerTitles(_geometryCache); 220 | 221 | // Remove the currently displayed marker floating titles that are in conflict with another displayed marker floating title 222 | removeConflictedMarkerTitles(); 223 | 224 | // Determine the minimum z-index among the visible floating marker titles 225 | float minVisibleZIndex = 0F; 226 | 227 | // Update the displayed marker titles display area rectangles 228 | for (final MarkerInfo mi : displayedMarkersList) { 229 | final RectF currentArea = displayedMarkerIdToScreenRect.get(mi); 230 | //We only recompute the location, because the text size is still correct and expensive to calculate 231 | final Point newLocation = _geometryCache.getScreenLocation(mi.getCoordinates()); 232 | currentArea.set(// 233 | (float) newLocation.x + textPaddingToMarker,// 234 | (float) newLocation.y - currentArea.height() / 2,// 235 | (float) newLocation.x + textPaddingToMarker + currentArea.width(),// 236 | (float) newLocation.y + currentArea.height() / 2// 237 | ); 238 | if (minVisibleZIndex > mi.getZIndex()) { 239 | minVisibleZIndex = mi.getZIndex(); 240 | } 241 | } 242 | 243 | // Prepare the list of markers to add 244 | final List markersToAdd = computeMarkersToAdd(_geometryCache, minVisibleZIndex); 245 | 246 | // Fill the displayed markers list with markers to check 247 | for (final MarkerInfo mi : markersToAdd) { 248 | final RectF displayAreaRect = _geometryCache.computeDisplayAreaRect(mi); 249 | displayedMarkersList.add(mi); 250 | displayedMarkerIdToScreenRect.put(mi, displayAreaRect); 251 | displayedMarkerIdToAddedTime.put(mi, System.currentTimeMillis()); 252 | } 253 | } 254 | 255 | private void removeOutOfViewMarkerTitles(@NonNull final GMFMTGeometryCache _geometryCache) { 256 | for (int i = displayedMarkersList.size() - 1; i >= 0; i--) { 257 | final MarkerInfo mi = displayedMarkersList.get(i); 258 | boolean needToRemove = false; 259 | if (mi.isVisible()) { 260 | if (!_geometryCache.isInScreenBounds(mi.getCoordinates())) { 261 | needToRemove = true; 262 | } 263 | } else { 264 | needToRemove = true; 265 | } 266 | if (!needToRemove) { 267 | continue; 268 | } 269 | displayedMarkersList.remove(i); 270 | displayedMarkerIdToScreenRect.remove(mi); 271 | displayedMarkerIdToAddedTime.remove(mi); 272 | } 273 | } 274 | 275 | private void removeConflictedMarkerTitles() { 276 | final List markerInfoToRemove = new ArrayList<>(); 277 | 278 | float minZIndex = 0; 279 | for (final MarkerInfo mi : displayedMarkerIdToScreenRect.keySet()) { 280 | if (mi.getZIndex() < minZIndex) { 281 | minZIndex = mi.getZIndex(); 282 | } 283 | for (final MarkerInfo mi2 : displayedMarkerIdToScreenRect.keySet()) { 284 | if (mi == mi2) { 285 | continue; 286 | } 287 | if (markerInfoToRemove.contains(mi)) { 288 | continue; 289 | } 290 | if (markerInfoToRemove.contains(mi2)) { 291 | continue; 292 | } 293 | final boolean displayConflict; 294 | final RectF miDisplayArea = displayedMarkerIdToScreenRect.get(mi); 295 | final RectF mi2DisplayArea = displayedMarkerIdToScreenRect.get(mi2); 296 | displayConflict = RectF.intersects(miDisplayArea, mi2DisplayArea); 297 | if (displayConflict) { 298 | if (mi.getZIndex() > mi2.getZIndex()) { 299 | markerInfoToRemove.add(mi2); 300 | } else { 301 | markerInfoToRemove.add(mi); 302 | } 303 | 304 | break; 305 | } 306 | } 307 | } 308 | 309 | for (int i = 0;// 310 | i < displayedMarkersList.size() &&// 311 | displayedMarkersList.size() - markerInfoToRemove.size() > maxFloatingTitlesCount// 312 | ; i++) { 313 | final MarkerInfo mi = displayedMarkersList.get(i); 314 | if (!markerInfoToRemove.contains(mi) && mi.getZIndex() == minZIndex) { 315 | markerInfoToRemove.add(mi); 316 | } 317 | } 318 | 319 | for (final MarkerInfo mi : markerInfoToRemove) { 320 | displayedMarkersList.remove(mi); 321 | displayedMarkerIdToScreenRect.remove(mi); 322 | displayedMarkerIdToAddedTime.remove(mi); 323 | } 324 | } 325 | 326 | /** 327 | * Determines the list of markers to add next. Since the number of markers we will check is limited by maxNewMarkersCheckPerFrame, the 328 | * list rotation is essential to ensure all the markers in the list are checked eventually (over several draw() calls). 329 | *

330 | * The created list will attempt to respect maxFloatingTitlesCount. However if some markers have a higher z-index than _minZIndex, they 331 | * will still be added, which will make the limit go over for the current frame. 332 | * On the next frame however, lower z-indexes will be discared. 333 | */ 334 | @NonNull 335 | private List computeMarkersToAdd(@NonNull final GMFMTGeometryCache _geometryCache, final float _minZIndex) { 336 | final ArrayList markersToAdd = new ArrayList<>(); 337 | 338 | // Adding the maximum number of markers to markersToAdd 339 | final int numberOfMarkersToCheck = Math.min(markerInfoList.size(), maxNewMarkersCheckPerFrame); 340 | for (int i = 0; i < numberOfMarkersToCheck; i++) { 341 | // List rotation, we will take the first element of the list and put it to the end, numberOfMarkersToCheck times 342 | final MarkerInfo mi = markerInfoList.remove(0); 343 | markerInfoList.add(mi); 344 | 345 | if (!mi.isVisible()) { 346 | // If the marker is not visible, we don't add it 347 | continue; 348 | } 349 | if (displayedMarkersList.contains(mi)) { 350 | // If the marker is already in the displayed markers, we don't add it 351 | continue; 352 | } 353 | 354 | if (isMarkerTitleInConflictWithDisplay(_geometryCache, mi)) { 355 | // If the marker is in conflict with display, we don't add it 356 | continue; 357 | } 358 | 359 | markersToAdd.add(mi); 360 | } 361 | 362 | // While we're above display limit count, we remove markers without a stricly higher z-index than _minZIndex 363 | final int remainingDisplaySlots = maxFloatingTitlesCount - displayedMarkersList.size(); 364 | 365 | for (int i = markersToAdd.size() - 1; i >= 0 && remainingDisplaySlots < markersToAdd.size(); i--) { 366 | final MarkerInfo mi = markersToAdd.get(i); 367 | if (!_geometryCache.isInScreenBounds(mi.getCoordinates())) { 368 | // If the marker is not visible, we remove it 369 | markersToAdd.remove(i); 370 | } 371 | } 372 | for (int i = markersToAdd.size() - 1; i >= 0 && remainingDisplaySlots < markersToAdd.size(); i--) { 373 | final MarkerInfo mi = markersToAdd.get(i); 374 | if (mi.getZIndex() <= _minZIndex) { 375 | markersToAdd.remove(i); 376 | } 377 | } 378 | 379 | return markersToAdd; 380 | } 381 | 382 | private boolean isMarkerTitleInConflictWithDisplay(final GMFMTGeometryCache _geometryCache, final MarkerInfo _markerInfo) { 383 | final RectF displayAreaRect = _geometryCache.computeDisplayAreaRect(_markerInfo); 384 | for (final MarkerInfo mi2 : displayedMarkerIdToScreenRect.keySet()) { 385 | final RectF rect = displayedMarkerIdToScreenRect.get(mi2); 386 | if (RectF.intersects(rect, displayAreaRect)) { 387 | // If _markerInfo is in conflict with another marker, we compare the z-index 388 | if (_markerInfo.getZIndex() <= mi2.getZIndex()) { 389 | // If _markerInfo has equal or lower Z-index, it's considered in conflict with display 390 | return true; 391 | } 392 | // If _markerInfo has higher Z-index, it's considered prioritary compared to the other marker 393 | } 394 | } 395 | return false; 396 | } 397 | 398 | private void drawMarkerFloatingTitle(final @NonNull Canvas _canvas, @Nullable final MarkerInfo _markerInfo) { 399 | if (_markerInfo == null) { 400 | return; 401 | } 402 | final RectF displayArea = displayedMarkerIdToScreenRect.get(_markerInfo); 403 | if (displayArea == null) { 404 | return; 405 | } 406 | final Long addedTime = displayedMarkerIdToAddedTime.get(_markerInfo); 407 | final int alpha = computeMarkerFloatingTitleAlpha(addedTime); 408 | drawMarkerFloatingTitleOnCanvas(_canvas, _markerInfo, displayArea, alpha); 409 | } 410 | 411 | private int computeMarkerFloatingTitleAlpha(@Nullable final Long _addedTime) { 412 | final int alpha; 413 | if (_addedTime == null) { 414 | alpha = 255; 415 | } else { 416 | long currentTimeMillis = System.currentTimeMillis(); 417 | if (currentTimeMillis < _addedTime) { 418 | alpha = 255; 419 | } else { 420 | final long elapsedTime = currentTimeMillis - _addedTime; 421 | if (elapsedTime > FADE_ANIMATION_TIME) { 422 | alpha = 255; 423 | } else { 424 | alpha = (int) ((float) elapsedTime / (float) FADE_ANIMATION_TIME * 255F); 425 | } 426 | } 427 | } 428 | return alpha; 429 | } 430 | 431 | private void drawMarkerFloatingTitleOnCanvas(final @NonNull Canvas _canvas, @NonNull final MarkerInfo _markerInfo, 432 | @NonNull final RectF _displayArea, final int _alpha) { 433 | final int markerColor = _markerInfo.getColor(); 434 | final String markerTitle = _markerInfo.getTitle(); 435 | final TextPaint usedTextPaint = _markerInfo.isBoldText() ? boldTextPaint : regularTextPaint; 436 | usedTextPaint.setStyle(Paint.Style.STROKE); 437 | if (GMFMTUtils.isDarkColor(markerColor)) { 438 | usedTextPaint.setColor(Color.WHITE); 439 | usedTextPaint.setAlpha((int) (_alpha / 1.2F)); 440 | } else { 441 | usedTextPaint.setColor(Color.BLACK); 442 | usedTextPaint.setAlpha((int) (_alpha / 2F)); 443 | } 444 | final String truncatedText = GMFMTUtils.getTruncatedText(usedTextPaint, maxTextWidth, _displayArea.height(), markerTitle); 445 | if (truncatedText == null) { 446 | return; 447 | } 448 | GMFMTUtils.drawMultiLineText(// 449 | _canvas,// 450 | usedTextPaint,// 451 | _displayArea.left,// 452 | _displayArea.top,// 453 | (float) Math.ceil(_displayArea.width()),// 454 | truncatedText// 455 | ); 456 | usedTextPaint.setStyle(Paint.Style.FILL); 457 | usedTextPaint.setColor(markerColor); 458 | usedTextPaint.setAlpha(_alpha); 459 | GMFMTUtils.drawMultiLineText(// 460 | _canvas,// 461 | usedTextPaint,// 462 | _displayArea.left,// 463 | _displayArea.top,// 464 | (float) Math.ceil(_displayArea.width()),// 465 | truncatedText// 466 | ); 467 | } 468 | } 469 | -------------------------------------------------------------------------------- /googlemapsfloatingmarkertitles/src/main/java/com/exlyo/gmfmt/GMFMTGeometryCache.java: -------------------------------------------------------------------------------- 1 | package com.exlyo.gmfmt; 2 | 3 | import android.graphics.Canvas; 4 | import android.graphics.Point; 5 | import android.graphics.Rect; 6 | import android.graphics.RectF; 7 | import android.support.annotation.NonNull; 8 | import android.support.annotation.Nullable; 9 | import android.text.TextPaint; 10 | 11 | import com.google.android.gms.maps.GoogleMap; 12 | import com.google.android.gms.maps.model.CameraPosition; 13 | import com.google.android.gms.maps.model.LatLng; 14 | 15 | import java.util.HashMap; 16 | import java.util.Iterator; 17 | import java.util.Map; 18 | 19 | class GMFMTGeometryCache { 20 | @NonNull 21 | private final FloatingMarkerTitlesOverlay fmto; 22 | @NonNull 23 | private final Rect viewBounds; 24 | @NonNull 25 | private final GoogleMap googleMap; 26 | @NonNull 27 | private final Map cacheMap = new HashMap<>(); 28 | 29 | @Nullable 30 | private CameraPosition lastFrameCameraPosition = null; 31 | 32 | GMFMTGeometryCache(@NonNull final FloatingMarkerTitlesOverlay _fmto, @NonNull final GoogleMap _googleMap) { 33 | fmto = _fmto; 34 | viewBounds = new Rect(0, 0, 1, 1); 35 | googleMap = _googleMap; 36 | } 37 | 38 | /** 39 | * Called by the parent FloatingMarkerTitlesOverlay before drawing every frame. Updates information important for the cache and gets 40 | * to a ready state to draw the next frame. 41 | */ 42 | public void prepareForNewFrame(@NonNull final Canvas _canvas) { 43 | viewBounds.right = GMFMTUtils.getCanvasWidth(_canvas); 44 | viewBounds.bottom = GMFMTUtils.getCanvasHeight(_canvas); 45 | final CameraPosition cameraPosition = googleMap.getCameraPosition(); 46 | if (lastFrameCameraPosition != null) { 47 | smartCacheUpdate(lastFrameCameraPosition, cameraPosition); 48 | } 49 | lastFrameCameraPosition = cameraPosition; 50 | } 51 | 52 | /** 53 | * Updates the cache content for cacheMap in a smart way: normally, each floating marker title's location on screen needs to be 54 | * calculated using the following code: googleMap.getProjection().toScreenLocation(_latLng) 55 | *

56 | * In the case the map's zoom level or bearing hasn't changed any marker's screen location will receive the same update/translation. The 57 | * translation is applicable to any marker already cached, so in this case we will only compute the updated screen location for one 58 | * marker, calculate the deltaX and deltaY, and apply that change to all cached screen locations. 59 | */ 60 | private void smartCacheUpdate(@NonNull final CameraPosition _previousCameraPosition, @NonNull final CameraPosition _cameraPosition) { 61 | if (cacheMap.isEmpty()) { 62 | // If the cache map is empty, there is nothing smart to do about it anyways 63 | return; 64 | } 65 | 66 | if (_previousCameraPosition.equals(_cameraPosition)) { 67 | // If the camera position hasn't changed, then the cache doesn't need any change, returning here 68 | return; 69 | } 70 | 71 | // Check for cases where we cannot use a smart update 72 | if (_cameraPosition.tilt != 0 // The tilt of the camera is not 0 (aka perspective aka multiplier applied to coordinates) 73 | || _previousCameraPosition.zoom != _cameraPosition.zoom // The zoom changed (aka multiplier applied to coordinates) 74 | || _previousCameraPosition.bearing != _cameraPosition.bearing // The bearing changed (aka rotation applied to coordinates) 75 | ) { 76 | // If anything else than the target of the camera position has changed, we cannot use a smart update 77 | cacheMap.clear(); 78 | return; 79 | } 80 | 81 | final Iterator iterator = cacheMap.keySet().iterator(); 82 | final LatLng latLngSample = iterator.next(); 83 | final Point pointSample = cacheMap.get(latLngSample); 84 | final Point updatedPointSample = googleMap.getProjection().toScreenLocation(latLngSample); 85 | final int deltaX = updatedPointSample.x - pointSample.x; 86 | final int deltaY = updatedPointSample.y - pointSample.y; 87 | for (final Point p : cacheMap.values()) { 88 | p.x += deltaX; 89 | p.y += deltaY; 90 | } 91 | } 92 | 93 | @NonNull 94 | public Point getScreenLocation(@NonNull final LatLng _latLng) { 95 | final Point cacheRes = cacheMap.get(_latLng); 96 | if (cacheRes != null) { 97 | return cacheRes; 98 | } 99 | final Point res = googleMap.getProjection().toScreenLocation(_latLng); 100 | cacheMap.put(_latLng, res); 101 | return res; 102 | } 103 | 104 | @NonNull 105 | public RectF computeDisplayAreaRect(@NonNull final MarkerInfo _markerInfo) { 106 | final Point screenLocation = getScreenLocation(_markerInfo.getCoordinates()); 107 | final TextPaint usedTextPaint = _markerInfo.isBoldText() ? fmto.boldTextPaint : fmto.regularTextPaint; 108 | final Point textSize = GMFMTUtils.measureMultiLineEllipsizedText(// 109 | usedTextPaint,// 110 | (int) fmto.maxTextWidth,// 111 | (int) fmto.maxTextHeight,// 112 | _markerInfo.getTitle()// 113 | ); 114 | final float left = screenLocation.x + fmto.textPaddingToMarker; 115 | final int top = screenLocation.y - textSize.y / 2; 116 | final float right = screenLocation.x + textSize.x + fmto.textPaddingToMarker; 117 | final int bottom = screenLocation.y + textSize.y / 2; 118 | return new RectF(left, top, right, bottom); 119 | } 120 | 121 | public boolean isInScreenBounds(@NonNull final LatLng _coordinates) { 122 | final Point point = getScreenLocation(_coordinates); 123 | return viewBounds.contains(point.x, point.y); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /googlemapsfloatingmarkertitles/src/main/java/com/exlyo/gmfmt/GMFMTUtils.java: -------------------------------------------------------------------------------- 1 | package com.exlyo.gmfmt; 2 | 3 | import android.content.Context; 4 | import android.graphics.Canvas; 5 | import android.graphics.Color; 6 | import android.graphics.Point; 7 | import android.graphics.Rect; 8 | import android.support.annotation.ColorInt; 9 | import android.support.annotation.NonNull; 10 | import android.support.annotation.Nullable; 11 | import android.text.Layout; 12 | import android.text.StaticLayout; 13 | import android.text.TextPaint; 14 | import android.util.DisplayMetrics; 15 | import android.util.TypedValue; 16 | 17 | final class GMFMTUtils { 18 | private static final float MIN_LUMINANCE_TO_LIGHT_TINTING = 0.75F; 19 | 20 | private static float colorLuminance(@ColorInt int _color) { 21 | final float red = Color.red(_color) / 255F; 22 | final float green = Color.green(_color) / 255F; 23 | final float blue = Color.blue(_color) / 255F; 24 | 25 | return 0.2126F * red + 0.7152F * green + 0.0722F * blue; 26 | } 27 | 28 | public static boolean isDarkColor(@ColorInt final int _color) { 29 | return colorLuminance(_color) < MIN_LUMINANCE_TO_LIGHT_TINTING; 30 | } 31 | 32 | public static float dipToPixels(final Context _context, final float _dipValue) { 33 | final DisplayMetrics metrics = _context.getResources().getDisplayMetrics(); 34 | return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, _dipValue, metrics); 35 | } 36 | 37 | public static int getCanvasWidth(@NonNull final Canvas _canvas) { 38 | final Rect clipBounds = _canvas.getClipBounds(); 39 | return Math.abs(clipBounds.right - clipBounds.left); 40 | } 41 | 42 | public static int getCanvasHeight(@NonNull final Canvas _canvas) { 43 | final Rect clipBounds = _canvas.getClipBounds(); 44 | return Math.abs(clipBounds.bottom - clipBounds.top); 45 | } 46 | 47 | /** 48 | * Computes the screen space (width and height) occupied by some text with a given text paint, if the text needed to fit in a given 49 | * width/height with ellipsis 50 | */ 51 | public static Point measureMultiLineEllipsizedText(@NonNull final TextPaint _textPaint, final int _maxWidth, final int _maxHeight, 52 | @NonNull final String _text) { 53 | final StaticLayout measuringTextLayout = 54 | new StaticLayout(_text, _textPaint, _maxWidth, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false); 55 | final int resWidth; 56 | final int resHeight; 57 | if (measuringTextLayout.getLineCount() == 1) { 58 | final Rect lineBounds = new Rect(); 59 | measuringTextLayout.getLineBounds(0, lineBounds); 60 | resWidth = (int) Math.ceil(_textPaint.measureText(_text)); 61 | resHeight = measuringTextLayout.getHeight(); 62 | } else { 63 | resWidth = measuringTextLayout.getWidth(); 64 | resHeight = Math.min(_maxHeight, measuringTextLayout.getHeight()); 65 | } 66 | return new Point(resWidth, resHeight); 67 | } 68 | 69 | public static void drawMultiLineText(@NonNull final Canvas _canvas, @NonNull final TextPaint _textPaint, final float _x, final float _y, 70 | final float _width, @NonNull final String _text) { 71 | final StaticLayout drawingTextLayout = 72 | new StaticLayout(_text, _textPaint, (int) Math.abs(_width), Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false); 73 | 74 | _canvas.save(); 75 | _canvas.translate(_x, _y); 76 | drawingTextLayout.draw(_canvas); 77 | _canvas.restore(); 78 | } 79 | 80 | @Nullable 81 | public static String getTruncatedText(final @NonNull TextPaint _textPaint, final float _width, final float _height, 82 | final @NonNull String _text) { 83 | if (_text.length() < 3) { 84 | return _text; 85 | } 86 | final StaticLayout measuringTextLayout = 87 | new StaticLayout(_text, _textPaint, (int) Math.abs(_width), Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false); 88 | 89 | final int totalLineCount = measuringTextLayout.getLineCount(); 90 | 91 | int line; 92 | for (line = 1; line < totalLineCount; line++) { 93 | final int lineBottom = measuringTextLayout.getLineBottom(line); 94 | if (lineBottom > _height) { 95 | break; 96 | } 97 | } 98 | line--; 99 | 100 | if (line < 0) { 101 | return null; 102 | } 103 | 104 | int lineEnd; 105 | try { 106 | lineEnd = measuringTextLayout.getLineEnd(line); 107 | } catch (Throwable t) { 108 | lineEnd = _text.length(); 109 | } 110 | String truncatedText = _text.substring(0, Math.max(0, lineEnd)); 111 | 112 | if (truncatedText.length() < 3) { 113 | return null; 114 | } 115 | 116 | if (truncatedText.length() < _text.length()) { 117 | truncatedText = truncatedText.substring(0, Math.max(0, truncatedText.length() - 3)); 118 | truncatedText += "..."; 119 | } 120 | return truncatedText; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /googlemapsfloatingmarkertitles/src/main/java/com/exlyo/gmfmt/MarkerInfo.java: -------------------------------------------------------------------------------- 1 | package com.exlyo.gmfmt; 2 | 3 | import android.support.annotation.NonNull; 4 | import android.support.annotation.Nullable; 5 | 6 | import com.google.android.gms.maps.model.LatLng; 7 | import com.google.android.gms.maps.model.Marker; 8 | 9 | /** 10 | * Basic information of a marker used to display as floating text: its coordinates and its title 11 | */ 12 | @SuppressWarnings({"unused", "WeakerAccess"}) 13 | public class MarkerInfo { 14 | @Nullable 15 | private Marker marker; 16 | @NonNull 17 | private LatLng coordinates; 18 | @NonNull 19 | private String title; 20 | private int color; 21 | private boolean visible; 22 | private float zIndex; 23 | private boolean boldText; 24 | 25 | public MarkerInfo(@NonNull final LatLng _coordinates, @NonNull final String _title, final int _color) { 26 | this(_coordinates, _title, _color, true); 27 | } 28 | 29 | public MarkerInfo(@NonNull final Marker _marker, final int _color) { 30 | this(_marker.getPosition(), _marker.getTitle(), _color, _marker.isVisible()); 31 | marker = _marker; 32 | } 33 | 34 | private MarkerInfo(@NonNull final LatLng _coordinates, @NonNull final String _title, final int _color, final boolean _visible) { 35 | coordinates = _coordinates; 36 | title = _title; 37 | color = _color; 38 | visible = _visible; 39 | } 40 | 41 | public MarkerInfo setCoordinates(@NonNull final LatLng _coordinates) { 42 | coordinates = _coordinates; 43 | return this; 44 | } 45 | 46 | public MarkerInfo setTitle(@NonNull final String _title) { 47 | title = _title; 48 | return this; 49 | } 50 | 51 | public MarkerInfo setVisible(final boolean _visible) { 52 | visible = _visible; 53 | return this; 54 | } 55 | 56 | /** 57 | * Sets the Z index of the marker info. 58 | *

59 | * The z-index specifies the stack order of this marker, relative to other markers on the map. A marker with a high z-index will have 60 | * its floating title drawn on top of floating titles for markers with lower z-indexes. The default z-index value is 0. 61 | * 62 | * @param _zIndex: the desired z-index 63 | */ 64 | public MarkerInfo setZIndex(final float _zIndex) { 65 | zIndex = _zIndex; 66 | return this; 67 | } 68 | 69 | public MarkerInfo setBoldText(final boolean _boldText) { 70 | boldText = _boldText; 71 | return this; 72 | } 73 | 74 | /** 75 | * Sets a marker as source of the information. 76 | * This will clear the previous values passed to setCoordinates() and setTitle() 77 | */ 78 | public MarkerInfo setMarker(@NonNull final Marker _marker) { 79 | marker = _marker; 80 | coordinates = _marker.getPosition(); 81 | title = marker.getTitle(); 82 | visible = marker.isVisible(); 83 | return this; 84 | } 85 | 86 | @NonNull 87 | public LatLng getCoordinates() { 88 | final Marker m = marker; 89 | if (m == null) { 90 | return coordinates; 91 | } else { 92 | return m.getPosition(); 93 | } 94 | } 95 | 96 | @NonNull 97 | public String getTitle() { 98 | final Marker m = marker; 99 | if (m == null) { 100 | return title; 101 | } else { 102 | return m.getTitle(); 103 | } 104 | } 105 | 106 | public int getColor() { 107 | return color; 108 | } 109 | 110 | public boolean isVisible() { 111 | final Marker m = marker; 112 | if (m == null) { 113 | return visible; 114 | } else { 115 | return m.isVisible(); 116 | } 117 | } 118 | 119 | public float getZIndex() { 120 | final Marker m = marker; 121 | if (m == null) { 122 | return zIndex; 123 | } else { 124 | return m.getZIndex(); 125 | } 126 | } 127 | 128 | public boolean isBoldText() { 129 | return boldText; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx1536m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/androidseb/android-google-maps-floating-marker-titles/d5b56cac1baccf2b03c10f50488f7761a9fdb5eb/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Aug 12 11:02:10 EDT 2018 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app', ':googlemapsfloatingmarkertitles' 2 | -------------------------------------------------------------------------------- /visual_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/androidseb/android-google-maps-floating-marker-titles/d5b56cac1baccf2b03c10f50488f7761a9fdb5eb/visual_demo.gif --------------------------------------------------------------------------------