diff --git a/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/NavigationView.java b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/NavigationView.java index 8f9fb0a8f6c..44e936c3040 100644 --- a/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/NavigationView.java +++ b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/NavigationView.java @@ -32,7 +32,7 @@ import com.mapbox.mapboxsdk.plugins.locationlayer.LocationLayerPlugin; import com.mapbox.mapboxsdk.plugins.locationlayer.modes.RenderMode; import com.mapbox.services.android.navigation.ui.v5.camera.NavigationCamera; -import com.mapbox.services.android.navigation.ui.v5.instruction.InstructionLoader; +import com.mapbox.services.android.navigation.ui.v5.instruction.ImageCoordinator; import com.mapbox.services.android.navigation.ui.v5.instruction.InstructionView; import com.mapbox.services.android.navigation.ui.v5.route.NavigationMapRoute; import com.mapbox.services.android.navigation.ui.v5.summary.SummaryBottomSheet; @@ -181,7 +181,7 @@ public void onRestoreInstanceState(Bundle savedInstanceState) { public void onDestroy() { mapView.onDestroy(); navigationViewModel.onDestroy(isChangingConfigurations()); - InstructionLoader.getInstance().shutdown(); + ImageCoordinator.getInstance().shutdown(); if (camera != null) { camera.onDestroy(); } @@ -404,7 +404,7 @@ public MapboxMap getMapboxMap() { } private void initializeView() { - InstructionLoader.getInstance().initialize(getContext()); + ImageCoordinator.getInstance().initialize(getContext()); inflate(getContext(), R.layout.navigation_view_layout, this); bind(); initializeNavigationViewModel(); diff --git a/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/AbbreviationCoordinator.java b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/AbbreviationCoordinator.java new file mode 100644 index 00000000000..1c1a08247b0 --- /dev/null +++ b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/AbbreviationCoordinator.java @@ -0,0 +1,152 @@ +package com.mapbox.services.android.navigation.ui.v5.instruction; + +import android.widget.TextView; + +import com.mapbox.api.directions.v5.models.BannerComponents; +import com.mapbox.services.android.navigation.ui.v5.instruction.InstructionLoader.BannerComponentNode; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * This class allows text to be constructed to fit a given TextView, given specified + * BannerComponents containing abbreviation information and given a list of BannerComponentNodes, + * constructed by InstructionLoader. + */ +class AbbreviationCoordinator { + private static final String SINGLE_SPACE = " "; + private Map> abbreviations; + private TextViewUtils textViewUtils; + + AbbreviationCoordinator(TextViewUtils textViewUtils) { + this.abbreviations = new HashMap<>(); + this.textViewUtils = textViewUtils; + } + + AbbreviationCoordinator() { + this(new TextViewUtils()); + } + + /** + * Adds the given BannerComponents object to the list of abbreviations so that when the list of + * BannerComponentNodes is completed, text can be abbreviated properly to fit the specified + * TextView. + * + * @param bannerComponents object holding the abbreviation information + * @param index in the list of BannerComponentNodes + */ + void addPriorityInfo(BannerComponents bannerComponents, int index) { + Integer abbreviationPriority = bannerComponents.abbreviationPriority(); + if (abbreviations.get(abbreviationPriority) == null) { + abbreviations.put(abbreviationPriority, new ArrayList()); + } + abbreviations.get(abbreviationPriority).add(index); + } + + /** + * Using the abbreviations HashMap which should already be populated, abbreviates the text in the + * bannerComponentNodes until the text fits the given TextView. + * + * @param bannerComponentNodes containing the text to construct + * @param textView to check the text fits + * @return the properly abbreviated string that will fit in the TextView + */ + String abbreviateBannerText(List bannerComponentNodes, TextView textView) { + String bannerText = join(bannerComponentNodes); + + if (abbreviations.isEmpty()) { + return bannerText; + } + + bannerText = abbreviateUntilTextFits(textView, bannerText, bannerComponentNodes); + + abbreviations.clear(); + return bannerText; + } + + private String abbreviateUntilTextFits(TextView textView, String startingText, + List bannerComponentNodes) { + int currAbbreviationPriority = 0; + int maxAbbreviationPriority = Collections.max(abbreviations.keySet()); + String bannerText = startingText; + + while (shouldKeepAbbreviating(textView, bannerText, currAbbreviationPriority, maxAbbreviationPriority)) { + List indices = abbreviations.get(currAbbreviationPriority++); + + boolean abbreviationPriorityExists = abbreviateAtAbbreviationPriority(bannerComponentNodes, indices); + + if (abbreviationPriorityExists) { + bannerText = join(bannerComponentNodes); + } + } + + return bannerText; + } + + private boolean shouldKeepAbbreviating(TextView textView, String bannerText, + int currAbbreviationPriority, int maxAbbreviationPriority) { + return !textViewUtils.textFits(textView, bannerText) && currAbbreviationPriority <= maxAbbreviationPriority; + } + + private boolean abbreviateAtAbbreviationPriority(List bannerComponentNodes, + List indices) { + if (indices == null) { + return false; + } + + for (Integer index : indices) { + abbreviate(bannerComponentNodes.get(index)); + } + + return true; + } + + private void abbreviate(BannerComponentNode bannerComponentNode) { + ((AbbreviationNode) bannerComponentNode).setAbbreviate(true); + } + + private String join(List tokens) { + StringBuilder stringBuilder = new StringBuilder(); + Iterator iterator = tokens.iterator(); + BannerComponentNode bannerComponentNode; + + if (iterator.hasNext()) { + bannerComponentNode = iterator.next(); + bannerComponentNode.setStartIndex(stringBuilder.length()); + stringBuilder.append(bannerComponentNode); + + while (iterator.hasNext()) { + stringBuilder.append(SINGLE_SPACE); + bannerComponentNode = iterator.next(); + bannerComponentNode.setStartIndex(stringBuilder.length()); + stringBuilder.append(bannerComponentNode); + } + } + + return stringBuilder.toString(); + } + + /** + * Class used by InstructionLoader to determine that a BannerComponent contains an abbreviation + */ + static class AbbreviationNode extends BannerComponentNode { + boolean abbreviate; + + AbbreviationNode(BannerComponents bannerComponents, int startIndex) { + super(bannerComponents, startIndex); + } + + @Override + public String toString() { + return abbreviate ? bannerComponents.abbreviation() : bannerComponents.text(); + } + + void setAbbreviate(boolean abbreviate) { + this.abbreviate = abbreviate; + } + } +} diff --git a/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/BannerShield.java b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/BannerShield.java new file mode 100644 index 00000000000..d3f80daacef --- /dev/null +++ b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/BannerShield.java @@ -0,0 +1,40 @@ +package com.mapbox.services.android.navigation.ui.v5.instruction; + +import com.mapbox.api.directions.v5.models.BannerComponents; + +class BannerShield { + private String url; + private String text; + private int nodeIndex; + private int startIndex = -1; + + BannerShield(BannerComponents bannerComponents, int nodeIndex) { + this.url = bannerComponents.imageBaseUrl(); + this.nodeIndex = nodeIndex; + this.text = bannerComponents.text(); + } + + String getUrl() { + return url; + } + + public String getText() { + return text; + } + + public int getNodeIndex() { + return nodeIndex; + } + + public void setStartIndex(int startIndex) { + this.startIndex = startIndex; + } + + public int getStartIndex() { + return startIndex; + } + + int getEndIndex() { + return startIndex + text.length(); + } +} diff --git a/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/BannerShieldInfo.java b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/BannerShieldInfo.java deleted file mode 100644 index dc33dbb220d..00000000000 --- a/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/BannerShieldInfo.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.mapbox.services.android.navigation.ui.v5.instruction; - -import android.content.Context; - -class BannerShieldInfo { - private String url; - private String text; - private int startIndex; - - BannerShieldInfo(Context context, String url, int startIndex, String text) { - this.url = new UrlDensityMap(context).get(url); - this.startIndex = startIndex; - this.text = text; - } - - String getUrl() { - return url; - } - - public String getText() { - return text; - } - - int getStartIndex() { - return startIndex; - } - - int getEndIndex() { - return startIndex + 1; - } -} diff --git a/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/ImageCoordinator.java b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/ImageCoordinator.java new file mode 100644 index 00000000000..5d51d0d4670 --- /dev/null +++ b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/ImageCoordinator.java @@ -0,0 +1,211 @@ +package com.mapbox.services.android.navigation.ui.v5.instruction; + +import android.content.Context; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.style.ImageSpan; +import android.widget.TextView; + +import com.mapbox.api.directions.v5.models.BannerComponents; +import com.mapbox.api.directions.v5.models.BannerInstructions; +import com.mapbox.api.directions.v5.models.BannerText; +import com.mapbox.api.directions.v5.models.LegStep; +import com.mapbox.services.android.navigation.ui.v5.instruction.InstructionLoader.BannerComponentNode; +import com.squareup.picasso.Picasso; + +import java.util.ArrayList; +import java.util.List; + +/** + * Utility class that can be used to load a given {@link BannerText} into the provided + * {@link TextView}. + *

+ * For each {@link BannerComponents}, either the text or given shield URL will be used (the shield + * URL taking priority). + *

+ * If a shield URL is found, {@link Picasso} is used to load the image. Then, once the image is loaded, + * a new {@link ImageSpan} is created and set to the appropriate position of the {@link Spannable} + */ +public class ImageCoordinator { + + private static ImageCoordinator instance; + private boolean isInitialized; + private Picasso picassoImageLoader; + private List targets; + private UrlDensityMap urlDensityMap; + private List bannerShieldList; + + private ImageCoordinator() { + } + + /** + * Primary access method (using singleton pattern) + * + * @return InstructionLoader + */ + public static synchronized ImageCoordinator getInstance() { + if (instance == null) { + instance = new ImageCoordinator(); + } + + return instance; + } + + /** + * Must be called before loading images. + *

+ * Initializes a new {@link Picasso} instance as well as the + * {@link ArrayList} of {@link InstructionTarget}. + * + * @param context to init Picasso + */ + public void initialize(Context context) { + if (!isInitialized) { + initializePicasso(context); + initializeData(context); + isInitialized = true; + } + } + + /** + * Uses the given BannerComponents object to construct a BannerShield object containing the + * information needed to load the proper image into the TextView where appropriate. + * + * @param bannerComponents containing image info + * @param index of the BannerComponentNode which refers to the given BannerComponents + */ + public void addShieldInfo(BannerComponents bannerComponents, int index) { + bannerShieldList.add(new BannerShield(bannerComponents, index)); + } + + /** + * Will pre-fetch images for a given {@link LegStep}. + *

+ * If loaded successfully, this will allow the images to be displayed + * without delay in the {@link InstructionView}. + * + * @param legStep providing the image Urls + */ + public void prefetchImageCache(LegStep legStep) { + checkIsInitialized(); + fetchInstructions(legStep); + } + + public void shutdown() { + targets.clear(); + } + + /** + * Takes the given components from the {@link BannerText} and creates + * a new {@link Spannable} with text / {@link ImageSpan}s which is loaded + * into the given {@link TextView}. + * + * @param textView target for the banner text + * @since 0.9.0 + */ + public void loadImages(TextView textView, List bannerComponentNodes) { + if (!hasImages()) { + return; + } + + updateShieldUrlIndices(bannerComponentNodes); + createTargets(textView); + loadTargets(); + } + + private void initializePicasso(Context context) { + Picasso.Builder builder = new Picasso.Builder(context) + .loggingEnabled(true); + picassoImageLoader = builder.build(); + } + + private void initializeData(Context context) { + urlDensityMap = new UrlDensityMap(context); + targets = new ArrayList<>(); + bannerShieldList = new ArrayList<>(); + } + + private void fetchInstructions(LegStep legStep) { + if (legStep == null || legStep.bannerInstructions() == null + || legStep.bannerInstructions().isEmpty()) { + return; + } + + List bannerInstructionList = new ArrayList<>(legStep.bannerInstructions()); + for (BannerInstructions instructions : bannerInstructionList) { + if (hasComponents(instructions.primary())) { + fetchImageBaseUrls(instructions.primary()); + } + if (hasComponents(instructions.secondary())) { + fetchImageBaseUrls(instructions.secondary()); + } + } + } + + private void updateShieldUrlIndices(List bannerComponentNodes) { + for (BannerShield bannerShield : bannerShieldList) { + bannerShield.setStartIndex(bannerComponentNodes.get(bannerShield.getNodeIndex()).startIndex); + } + } + + private boolean hasComponents(BannerText bannerText) { + return bannerText != null && bannerText.components() != null && !bannerText.components().isEmpty(); + } + + private boolean hasImages() { + return !bannerShieldList.isEmpty(); + } + + /** + * Takes a given {@link BannerText} and fetches a valid + * imageBaseUrl if one is found. + * + * @param bannerText to provide the base URL + */ + private void fetchImageBaseUrls(BannerText bannerText) { + for (BannerComponents components : bannerText.components()) { + if (hasImageUrl(components)) { + picassoImageLoader.load(urlDensityMap.get(components.imageBaseUrl())).fetch(); + } + } + } + + private boolean hasImageUrl(BannerComponents components) { + return !TextUtils.isEmpty(components.imageBaseUrl()); + } + + private void createTargets(TextView textView) { + Spannable instructionSpannable = new SpannableString(textView.getText()); + for (final BannerShield bannerShield : bannerShieldList) { + targets.add(new InstructionTarget(textView, instructionSpannable, bannerShieldList, bannerShield, + new InstructionTarget.InstructionLoadedCallback() { + @Override + public void onInstructionLoaded(InstructionTarget target) { + targets.remove(target); + } + })); + } + bannerShieldList.clear(); + } + + private void loadTargets() { + for (InstructionTarget target : new ArrayList<>(targets)) { + picassoImageLoader.load(urlDensityMap.get(target.getShield().getUrl())) + .into(target); + } + } + + private void checkIsInitialized() { + if (!isInitialized) { + throw new RuntimeException("InstructionLoader must be initialized prior to loading image URLs"); + } + } + + static class ImageNode extends BannerComponentNode { + + ImageNode(BannerComponents bannerComponents, int startIndex) { + super(bannerComponents, startIndex); + } + } +} diff --git a/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/InstructionLoader.java b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/InstructionLoader.java index 5f1ab70d264..a344818ccc7 100644 --- a/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/InstructionLoader.java +++ b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/InstructionLoader.java @@ -1,16 +1,15 @@ package com.mapbox.services.android.navigation.ui.v5.instruction; -import android.content.Context; +import android.support.annotation.NonNull; import android.text.Spannable; -import android.text.SpannableString; -import android.text.TextUtils; import android.text.style.ImageSpan; import android.widget.TextView; import com.mapbox.api.directions.v5.models.BannerComponents; -import com.mapbox.api.directions.v5.models.BannerInstructions; import com.mapbox.api.directions.v5.models.BannerText; -import com.mapbox.api.directions.v5.models.LegStep; +import com.mapbox.core.utils.TextUtils; +import com.mapbox.services.android.navigation.ui.v5.instruction.AbbreviationCoordinator.AbbreviationNode; +import com.mapbox.services.android.navigation.ui.v5.instruction.ImageCoordinator.ImageNode; import com.squareup.picasso.Picasso; import java.util.ArrayList; @@ -26,179 +25,106 @@ * If a shield URL is found, {@link Picasso} is used to load the image. Then, once the image is loaded, * a new {@link ImageSpan} is created and set to the appropriate position of the {@link Spannable}/ */ -public class InstructionLoader { - - private static InstructionLoader instance; - private boolean isInitialized; - private Picasso picassoImageLoader; - private List targets; - private UrlDensityMap urlDensityMap; - private static final String IMAGE_SPACE_PLACEHOLDER = " "; - private static final String SINGLE_SPACE = " "; - - private InstructionLoader() { +class InstructionLoader { + private ImageCoordinator imageCoordinator; + private AbbreviationCoordinator abbreviationCoordinator; + private TextView textView; + private List bannerComponentNodes; + + InstructionLoader(TextView textView, @NonNull List bannerComponents) { + this(textView, bannerComponents, ImageCoordinator.getInstance(), new AbbreviationCoordinator()); } - /** - * Primary access method (using singleton pattern) - * - * @return InstructionLoader - */ - public static synchronized InstructionLoader getInstance() { - if (instance == null) { - instance = new InstructionLoader(); - } + InstructionLoader(TextView textView, @NonNull List bannerComponents, + ImageCoordinator imageCoordinator, AbbreviationCoordinator abbreviationCoordinator) { + this.abbreviationCoordinator = abbreviationCoordinator; + this.textView = textView; + bannerComponentNodes = new ArrayList<>(); + this.imageCoordinator = imageCoordinator; - return instance; + bannerComponentNodes = parseBannerComponents(bannerComponents); } /** - * Must be called before loading images. - *

- * Initializes a new {@link Picasso} instance as well as the - * {@link ArrayList} of {@link InstructionTarget}. - * - * @param context to init Picasso + * Takes the given components from the {@link BannerText} and creates + * a new {@link Spannable} with text / {@link ImageSpan}s which is loaded + * into the given {@link TextView}. */ - public void initialize(Context context) { - if (!isInitialized) { - Picasso.Builder builder = new Picasso.Builder(context) - .loggingEnabled(true); - picassoImageLoader = builder.build(); - - this.urlDensityMap = new UrlDensityMap(context); - targets = new ArrayList<>(); - - isInitialized = true; - } + void loadInstruction() { + setText(textView, bannerComponentNodes); + loadImages(textView, bannerComponentNodes); } - /** - * Will pre-fetch images for a given {@link LegStep}. - *

- * If loaded successfully, this will allow the images to be displayed - * without delay in the {@link InstructionView}. - * - * @param step providing the image Urls - */ - public void prefetchImageCache(LegStep step) { + private List parseBannerComponents(List bannerComponents) { + int length = 0; + bannerComponentNodes = new ArrayList<>(); - if (step == null || step.bannerInstructions() == null - || step.bannerInstructions().isEmpty()) { - return; + for (BannerComponents components : bannerComponents) { + BannerComponentNode node; + if (hasImageUrl(components)) { + node = setupImageNode(components, bannerComponentNodes.size(), length - 1); + } else if (hasAbbreviation(components)) { + node = setupAbbreviationNode(components, bannerComponentNodes.size(), length - 1); + } else { + node = new BannerComponentNode(components, length - 1); + } + bannerComponentNodes.add(node); + length += components.text().length() + 1; } - checkIsInitialized(); + return bannerComponentNodes; + } - List bannerInstructionList = new ArrayList<>(step.bannerInstructions()); - for (BannerInstructions instructions : bannerInstructionList) { - if (hasComponents(instructions.primary())) { - fetchImageBaseUrls(instructions.primary()); - } - if (hasComponents(instructions.secondary())) { - fetchImageBaseUrls(instructions.secondary()); - } - } + private ImageNode setupImageNode(BannerComponents components, int index, int startIndex) { + imageCoordinator.addShieldInfo(components, index); + return new ImageNode(components, startIndex); } - public void shutdown() { - targets.clear(); + private AbbreviationNode setupAbbreviationNode(BannerComponents components, int index, int startIndex) { + abbreviationCoordinator.addPriorityInfo(components, index); + return new AbbreviationCoordinator.AbbreviationNode(components, startIndex); } - /** - * Takes the given components from the {@link BannerText} and creates - * a new {@link Spannable} with text / {@link ImageSpan}s which is loaded - * into the given {@link TextView}. - * - * @param textView target for the banner text - * @param bannerText with components to be extracted - * @since 0.9.0 - */ - public void loadInstruction(TextView textView, BannerText bannerText) { - - checkIsInitialized(); - - if (hasComponents(bannerText)) { - StringBuilder instructionStringBuilder = new StringBuilder(); - List shieldUrls = new ArrayList<>(); - - for (BannerComponents components : bannerText.components()) { - if (hasBaseUrl(components)) { - addShieldInfo(textView, instructionStringBuilder, shieldUrls, components); - } else { - String text = components.text(); - boolean emptyText = TextUtils.isEmpty(instructionStringBuilder.toString()); - String instructionText = emptyText ? text : SINGLE_SPACE.concat(text); - instructionStringBuilder.append(instructionText); - } - } + private void loadImages(TextView textView, List bannerComponentNodes) { + imageCoordinator.loadImages(textView, bannerComponentNodes); + } - // If there are shield Urls, fetch the corresponding images - if (!shieldUrls.isEmpty()) { - createTargets(textView, instructionStringBuilder, shieldUrls); - loadTargets(); - } else { - textView.setText(instructionStringBuilder); - } - } + private void setText(TextView textView, List bannerComponentNodes) { + String text = getAbbreviatedBannerText(textView, bannerComponentNodes); + textView.setText(text); } - private static boolean hasComponents(BannerText bannerText) { - return bannerText != null && bannerText.components() != null && !bannerText.components().isEmpty(); + private String getAbbreviatedBannerText(TextView textView, List bannerComponentNodes) { + return abbreviationCoordinator.abbreviateBannerText(bannerComponentNodes, textView); } - /** - * Takes a given {@link BannerText} and fetches a valid - * imageBaseUrl if one is found. - * - * @param bannerText to provide the base URL - */ - private void fetchImageBaseUrls(BannerText bannerText) { - for (BannerComponents components : bannerText.components()) { - if (hasBaseUrl(components)) { - picassoImageLoader.load(urlDensityMap.get(components.imageBaseUrl())).fetch(); - } - } + private boolean hasAbbreviation(BannerComponents components) { + return !TextUtils.isEmpty(components.abbreviation()); } - private static boolean hasBaseUrl(BannerComponents components) { + private boolean hasImageUrl(BannerComponents components) { return !TextUtils.isEmpty(components.imageBaseUrl()); } - private static void addShieldInfo(TextView textView, StringBuilder instructionStringBuilder, - List shieldUrls, BannerComponents components) { - boolean instructionBuilderEmpty = TextUtils.isEmpty(instructionStringBuilder.toString()); - int instructionLength = instructionStringBuilder.length(); - int startIndex = instructionBuilderEmpty ? instructionLength : instructionLength + 1; - shieldUrls.add(new BannerShieldInfo(textView.getContext(), components.imageBaseUrl(), - startIndex, components.text())); - instructionStringBuilder.append(IMAGE_SPACE_PLACEHOLDER); - } + /** + * Class used to construct a list of BannerComponents to be populated into a TextView + */ + static class BannerComponentNode { + BannerComponents bannerComponents; + int startIndex; - private void createTargets(TextView textView, StringBuilder instructionStringBuilder, - List shields) { - Spannable instructionSpannable = new SpannableString(instructionStringBuilder); - for (final BannerShieldInfo shield : shields) { - targets.add(new InstructionTarget(textView, instructionSpannable, shields, shield, - new InstructionTarget.InstructionLoadedCallback() { - @Override - public void onInstructionLoaded(InstructionTarget target) { - targets.remove(target); - } - })); + BannerComponentNode(BannerComponents bannerComponents, int startIndex) { + this.bannerComponents = bannerComponents; + this.startIndex = startIndex; } - } - private void loadTargets() { - for (InstructionTarget target : new ArrayList<>(targets)) { - picassoImageLoader.load(target.getShield().getUrl()) - .into(target); + @Override + public String toString() { + return bannerComponents.text(); } - } - private void checkIsInitialized() { - if (!isInitialized) { - throw new RuntimeException("InstructionLoader must be initialized prior to loading image URLs"); + public void setStartIndex(int startIndex) { + this.startIndex = startIndex; } } } diff --git a/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/InstructionTarget.java b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/InstructionTarget.java index f27d80bbd89..20576ca9dd5 100644 --- a/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/InstructionTarget.java +++ b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/InstructionTarget.java @@ -20,12 +20,12 @@ public class InstructionTarget implements Target { private TextView textView; private Spannable instructionSpannable; - private List shields; - private BannerShieldInfo shield; + private List shields; + private BannerShield shield; private InstructionLoadedCallback instructionLoadedCallback; InstructionTarget(TextView textView, Spannable instructionSpannable, - List shields, BannerShieldInfo shield, + List shields, BannerShield shield, InstructionLoadedCallback instructionLoadedCallback) { this.textView = textView; this.instructionSpannable = instructionSpannable; @@ -34,50 +34,55 @@ public class InstructionTarget implements Target { this.instructionLoadedCallback = instructionLoadedCallback; } - BannerShieldInfo getShield() { + BannerShield getShield() { return shield; } @Override public void onBitmapLoaded(Bitmap bitmap, Picasso.LoadedFrom from) { - // Create a new Drawable with intrinsic bounds - Drawable drawable = new BitmapDrawable(textView.getContext().getResources(), bitmap); - - // width == (right - left), and height == (bottom - top) - int bottom = textView.getLineHeight(); - int right = bottom * bitmap.getWidth() / bitmap.getHeight(); - drawable.setBounds(0, 0, right, bottom); - - // Create and set a new ImageSpan at the given index with the Drawable - instructionSpannable.setSpan(new ImageSpan(drawable), - shield.getStartIndex(), shield.getEndIndex(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - - // Check if last in array, if so, set the text with the spannable - if (shields.indexOf(shield) == shields.size() - 1) { - // Make sure cut-off images aren't displayed at the end of the spannable - CharSequence truncatedSequence = truncateImageSpan(instructionSpannable, textView); - textView.setText(truncatedSequence); - } + Drawable drawable = createDrawable(bitmap); + createAndSetImageSpan(drawable); sendInstructionLoadedCallback(); } @Override public void onBitmapFailed(Drawable errorDrawable) { - // Set the backup text - textView.setText(shield.getText()); + setBackupText(); sendInstructionLoadedCallback(); Timber.e("Shield bitmap failed to load."); } @Override public void onPrepareLoad(Drawable placeHolderDrawable) { - + // no op } interface InstructionLoadedCallback { void onInstructionLoaded(InstructionTarget target); } + private void setBackupText() { + textView.setText(shield.getText()); + } + + private void createAndSetImageSpan(Drawable drawable) { + instructionSpannable.setSpan(new ImageSpan(drawable), + shield.getStartIndex(), shield.getEndIndex(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + if (shields.indexOf(shield) == shields.size() - 1) { + CharSequence truncatedSequence = truncateImageSpan(instructionSpannable, textView); + textView.setText(truncatedSequence); + } + } + + private Drawable createDrawable(Bitmap bitmap) { + Drawable drawable = new BitmapDrawable(textView.getContext().getResources(), bitmap); + int bottom = textView.getLineHeight(); + int right = bottom * bitmap.getWidth() / bitmap.getHeight(); + drawable.setBounds(0, 0, right, bottom); + return drawable; + } + private void sendInstructionLoadedCallback() { if (instructionLoadedCallback != null) { instructionLoadedCallback.onInstructionLoaded(this); diff --git a/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/InstructionView.java b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/InstructionView.java index ffaa1b9a43c..4baf58031e8 100644 --- a/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/InstructionView.java +++ b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/InstructionView.java @@ -35,6 +35,7 @@ import android.widget.RelativeLayout; import android.widget.TextView; +import com.mapbox.api.directions.v5.models.BannerText; import com.mapbox.api.directions.v5.models.IntersectionLanes; import com.mapbox.api.directions.v5.models.LegStep; import com.mapbox.services.android.navigation.ui.v5.NavigationViewModel; @@ -234,7 +235,7 @@ private void updateViews(InstructionModel model) { if (newStep(model.getProgress())) { // Pre-fetch the image URLs for the upcoming step LegStep upComingStep = model.getProgress().currentLegProgress().upComingStep(); - InstructionLoader.getInstance().prefetchImageCache(upComingStep); + ImageCoordinator.getInstance().prefetchImageCache(upComingStep); } } @@ -683,7 +684,7 @@ private void distanceText(InstructionModel model) { */ private void updateTextInstruction(InstructionModel model) { if (model.getPrimaryBannerText() != null) { - InstructionLoader.getInstance().loadInstruction(upcomingPrimaryText, model.getPrimaryBannerText()); + createInstructionLoader(upcomingPrimaryText, model.getPrimaryBannerText()).loadInstruction(); } if (model.getSecondaryBannerText() != null) { if (upcomingSecondaryText.getVisibility() == GONE) { @@ -691,7 +692,7 @@ private void updateTextInstruction(InstructionModel model) { upcomingPrimaryText.setMaxLines(1); adjustBannerTextVerticalBias(0.65f); } - InstructionLoader.getInstance().loadInstruction(upcomingSecondaryText, model.getSecondaryBannerText()); + createInstructionLoader(upcomingSecondaryText, model.getSecondaryBannerText()).loadInstruction(); } else { upcomingPrimaryText.setMaxLines(2); upcomingSecondaryText.setVisibility(GONE); @@ -699,6 +700,18 @@ private void updateTextInstruction(InstructionModel model) { } } + private InstructionLoader createInstructionLoader(TextView textView, BannerText bannerText) { + if (hasComponents(bannerText)) { + return new InstructionLoader(textView, bannerText.components()); + } else { + return null; + } + } + + private boolean hasComponents(BannerText bannerText) { + return bannerText != null && bannerText.components() != null && !bannerText.components().isEmpty(); + } + /** * Looks to see if we have a new step. * diff --git a/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/TextViewUtils.java b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/TextViewUtils.java new file mode 100644 index 00000000000..fb5665be7aa --- /dev/null +++ b/libandroid-navigation-ui/src/main/java/com/mapbox/services/android/navigation/ui/v5/instruction/TextViewUtils.java @@ -0,0 +1,13 @@ +package com.mapbox.services.android.navigation.ui.v5.instruction; + +import android.graphics.Paint; +import android.widget.TextView; + +class TextViewUtils { + + boolean textFits(TextView textView, String text) { + Paint paint = new Paint(textView.getPaint()); + float width = paint.measureText(text); + return width < textView.getWidth(); + } +} diff --git a/libandroid-navigation-ui/src/main/res/layout/instruction_content_layout.xml b/libandroid-navigation-ui/src/main/res/layout/instruction_content_layout.xml index 376c5f3644e..db024ded31d 100644 --- a/libandroid-navigation-ui/src/main/res/layout/instruction_content_layout.xml +++ b/libandroid-navigation-ui/src/main/res/layout/instruction_content_layout.xml @@ -84,7 +84,7 @@ android:gravity="center_vertical" android:includeFontPadding="false" android:lineSpacingMultiplier="0.8" - android:maxLines="2" + android:maxLines="1" android:minHeight="31dp" android:textColor="?attr/navigationViewBannerSecondaryText" android:textSize="26sp" diff --git a/libandroid-navigation-ui/src/test/java/com/mapbox/services/android/navigation/ui/v5/instruction/AbbreviationCoordinatorTest.java b/libandroid-navigation-ui/src/test/java/com/mapbox/services/android/navigation/ui/v5/instruction/AbbreviationCoordinatorTest.java new file mode 100644 index 00000000000..ece3f95acd5 --- /dev/null +++ b/libandroid-navigation-ui/src/test/java/com/mapbox/services/android/navigation/ui/v5/instruction/AbbreviationCoordinatorTest.java @@ -0,0 +1,62 @@ +package com.mapbox.services.android.navigation.ui.v5.instruction; + +import android.widget.TextView; + +import com.mapbox.api.directions.v5.models.BannerComponents; +import com.mapbox.services.android.navigation.ui.v5.BaseTest; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class AbbreviationCoordinatorTest extends BaseTest { + @Test + public void onAbbreviateBannerText_textIsAbbreviated() { + String abbreviation = "smtxt"; + BannerComponents bannerComponents = + BannerComponentsFaker.bannerComponents() + .abbreviation(abbreviation) + .abbreviationPriority(0) + .build(); + TextViewUtils textViewUtils = mock(TextViewUtils.class); + TextView textView = mock(TextView.class); + when(textViewUtils.textFits(textView, abbreviation)).thenReturn(true); + when(textViewUtils.textFits(textView, bannerComponents.text())).thenReturn(false); + AbbreviationCoordinator abbreviationCoordinator = new AbbreviationCoordinator(textViewUtils); + abbreviationCoordinator.addPriorityInfo(bannerComponents, 0); + List bannerComponentNodes = new ArrayList<>(); + bannerComponentNodes.add(new AbbreviationCoordinator.AbbreviationNode(bannerComponents, 0)); + + String abbreviatedTextFromCoordinator = abbreviationCoordinator.abbreviateBannerText(bannerComponentNodes, textView); + + assertEquals(abbreviation, abbreviatedTextFromCoordinator); + } + + @Test + public void onAbbreviateBannerText_textIsNotAbbreviated() { + String abbreviation = "smtxt"; + String text = "some text"; + BannerComponents bannerComponents = + BannerComponentsFaker.bannerComponents() + .abbreviation(abbreviation) + .abbreviationPriority(0) + .text(text) + .build(); + TextViewUtils textViewUtils = mock(TextViewUtils.class); + TextView textView = mock(TextView.class); + when(textViewUtils.textFits(textView, bannerComponents.text())).thenReturn(true); + AbbreviationCoordinator abbreviationCoordinator = new AbbreviationCoordinator(textViewUtils); + abbreviationCoordinator.addPriorityInfo(bannerComponents, 0); + List bannerComponentNodes = new ArrayList<>(); + bannerComponentNodes.add(new AbbreviationCoordinator.AbbreviationNode(bannerComponents, 0)); + + String abbreviatedTextFromCoordinator = abbreviationCoordinator.abbreviateBannerText(bannerComponentNodes, textView); + + assertEquals(text, abbreviatedTextFromCoordinator); + } +} diff --git a/libandroid-navigation-ui/src/test/java/com/mapbox/services/android/navigation/ui/v5/instruction/BannerComponentsFaker.java b/libandroid-navigation-ui/src/test/java/com/mapbox/services/android/navigation/ui/v5/instruction/BannerComponentsFaker.java new file mode 100644 index 00000000000..79096670d8b --- /dev/null +++ b/libandroid-navigation-ui/src/test/java/com/mapbox/services/android/navigation/ui/v5/instruction/BannerComponentsFaker.java @@ -0,0 +1,11 @@ +package com.mapbox.services.android.navigation.ui.v5.instruction; + +import com.mapbox.api.directions.v5.models.BannerComponents; + +class BannerComponentsFaker { + static BannerComponents.Builder bannerComponents() { + return BannerComponents.builder() + .type("some type") + .text("some text"); + } +} diff --git a/libandroid-navigation-ui/src/test/java/com/mapbox/services/android/navigation/ui/v5/instruction/InstructionLoaderTest.java b/libandroid-navigation-ui/src/test/java/com/mapbox/services/android/navigation/ui/v5/instruction/InstructionLoaderTest.java new file mode 100644 index 00000000000..0391f2edcaf --- /dev/null +++ b/libandroid-navigation-ui/src/test/java/com/mapbox/services/android/navigation/ui/v5/instruction/InstructionLoaderTest.java @@ -0,0 +1,90 @@ +package com.mapbox.services.android.navigation.ui.v5.instruction; + +import android.widget.TextView; + +import com.mapbox.api.directions.v5.models.BannerComponents; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class InstructionLoaderTest { + + @Test + public void onInstructionLoaderCreated_priorityInfoIsAdded() { + TextView textView = mock(TextView.class); + ImageCoordinator imageCoordinator = mock(ImageCoordinator.class); + AbbreviationCoordinator abbreviationCoordinator = mock(AbbreviationCoordinator.class); + BannerComponents bannerComponents = BannerComponentsFaker.bannerComponents() + .abbreviationPriority(1) + .abbreviation("abbreviation text") + .build(); + List bannerComponentsList = new ArrayList<>(); + bannerComponentsList.add(bannerComponents); + + new InstructionLoader(textView, bannerComponentsList, imageCoordinator, abbreviationCoordinator); + + verify(abbreviationCoordinator).addPriorityInfo(bannerComponents, 0); + } + + @Test + public void onInstructionLoaderCreated_shieldInfoIsAdded() { + TextView textView = mock(TextView.class); + ImageCoordinator imageCoordinator = mock(ImageCoordinator.class); + AbbreviationCoordinator abbreviationCoordinator = mock(AbbreviationCoordinator.class); + BannerComponents bannerComponents = BannerComponentsFaker.bannerComponents() + .imageBaseUrl("string url") + .build(); + List bannerComponentsList = new ArrayList<>(); + bannerComponentsList.add(bannerComponents); + + new InstructionLoader(textView, bannerComponentsList, imageCoordinator, abbreviationCoordinator); + + verify(imageCoordinator).addShieldInfo(bannerComponents, 0); + } + + @Test + public void onLoadInstruction_textIsAbbreviated() { + TextView textView = mock(TextView.class); + ImageCoordinator imageCoordinator = mock(ImageCoordinator.class); + AbbreviationCoordinator abbreviationCoordinator = mock(AbbreviationCoordinator.class); + BannerComponents bannerComponents = BannerComponentsFaker.bannerComponents() + .abbreviationPriority(1) + .abbreviation("abbrv text") + .build(); + List bannerComponentsList = new ArrayList<>(); + bannerComponentsList.add(bannerComponents); + String abbreviatedText = "abbreviated text"; + when(abbreviationCoordinator.abbreviateBannerText(any(List.class), any(TextView.class))).thenReturn(abbreviatedText); + InstructionLoader instructionLoader = new InstructionLoader(textView, bannerComponentsList, imageCoordinator, abbreviationCoordinator); + + instructionLoader.loadInstruction(); + + verify(textView).setText(abbreviatedText); + } + + @Test + public void onLoadInstruction_imagesAreLoaded() { + TextView textView = mock(TextView.class); + ImageCoordinator imageCoordinator = mock(ImageCoordinator.class); + AbbreviationCoordinator abbreviationCoordinator = mock(AbbreviationCoordinator.class); + BannerComponents bannerComponents = BannerComponentsFaker.bannerComponents() + .imageBaseUrl("string url") + .build(); + List bannerComponentsList = new ArrayList<>(); + bannerComponentsList.add(bannerComponents); + String abbreviatedText = "abbreviated text"; + when(abbreviationCoordinator.abbreviateBannerText(any(List.class), any(TextView.class))).thenReturn(abbreviatedText); + InstructionLoader instructionLoader = new InstructionLoader(textView, bannerComponentsList, imageCoordinator, abbreviationCoordinator); + + instructionLoader.loadInstruction(); + + verify(imageCoordinator).loadImages(any(TextView.class), any(List.class)); + } +} \ No newline at end of file