/* * Copyright (C) 2013 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ package com.google.android.glass.sample.compass; import com.google.android.glass.sample.compass.model.Place; import com.google.android.glass.sample.compass.util.MathUtils; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ValueAnimator; import android.animation.ValueAnimator.AnimatorUpdateListener; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Path; import android.graphics.Rect; import android.graphics.Typeface; import android.location.Location; import android.text.TextPaint; import android.util.AttributeSet; import android.view.View; import android.view.animation.LinearInterpolator; import java.io.File; import java.text.NumberFormat; import java.util.ArrayList; import java.util.List; /** * Draws a stylized compass, with text labels at the cardinal and ordinal directions, and tick * marks at the half-winds. The red "needles" in the display mark the current heading. */ public class CompassView extends View { /** Various dimensions and other drawing-related constants. */ private static final float NEEDLE_WIDTH = 6; private static final float NEEDLE_HEIGHT = 125; private static final int NEEDLE_COLOR = Color.RED; private static final float TICK_WIDTH = 2; private static final float TICK_HEIGHT = 10; private static final float DIRECTION_TEXT_HEIGHT = 84.0f; private static final float PLACE_TEXT_HEIGHT = 22.0f; private static final float PLACE_PIN_WIDTH = 14.0f; private static final float PLACE_TEXT_LEADING = 4.0f; private static final float PLACE_TEXT_MARGIN = 8.0f; /** * The maximum number of places names to allow to stack vertically underneath the compass * direction labels. */ private static final int MAX_OVERLAPPING_PLACE_NAMES = 4; /** * If the difference between two consecutive headings is less than this value, the canvas will * be redrawn immediately rather than animated. */ private static final float MIN_DISTANCE_TO_ANIMATE = 15.0f; /** The actual heading that represents the direction that the user is facing. */ private float mHeading; /** * Represents the heading that is currently being displayed when the view is drawn. This is * used during animations, to keep track of the heading that should be drawn on the current * frame, which may be different than the desired end point. */ private float mAnimatedHeading; private OrientationManager mOrientation; private List<Place> mNearbyPlaces; private final Paint mPaint; private final Paint mTickPaint; private final Path mPath; private final TextPaint mPlacePaint; private final Bitmap mPlaceBitmap; private final Rect mTextBounds; private final List<Rect> mAllBounds; private final NumberFormat mDistanceFormat; private final String[] mDirections; private final ValueAnimator mAnimator; public CompassView(Context context) { this(context, null, 0); } public CompassView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public CompassView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); mPaint = new Paint(); mPaint.setStyle(Paint.Style.FILL); mPaint.setAntiAlias(true); mPaint.setTextSize(DIRECTION_TEXT_HEIGHT); mPaint.setTypeface(Typeface.createFromFile(new File("/system/glass_fonts", "Roboto-Thin.ttf"))); mTickPaint = new Paint(); mTickPaint.setStyle(Paint.Style.STROKE); mTickPaint.setStrokeWidth(TICK_WIDTH); mTickPaint.setAntiAlias(true); mTickPaint.setColor(Color.WHITE); mPlacePaint = new TextPaint(); mPlacePaint.setStyle(Paint.Style.FILL); mPlacePaint.setAntiAlias(true); mPlacePaint.setColor(Color.WHITE); mPlacePaint.setTextSize(PLACE_TEXT_HEIGHT); mPlacePaint.setTypeface(Typeface.createFromFile(new File("/system/glass_fonts", "Roboto-Light.ttf"))); mPath = new Path(); mTextBounds = new Rect(); mAllBounds = new ArrayList<Rect>(); mDistanceFormat = NumberFormat.getNumberInstance(); mDistanceFormat.setMinimumFractionDigits(0); mDistanceFormat.setMaximumFractionDigits(1); mPlaceBitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.place_mark); // We use NaN to indicate that the compass is being drawn for the first // time, so that we can jump directly to the starting orientation // instead of spinning from a default value of 0. mAnimatedHeading = Float.NaN; mDirections = context.getResources().getStringArray(R.array.direction_abbreviations); mAnimator = new ValueAnimator(); setupAnimator(); } /** * Sets the instance of {@link OrientationManager} that this view will use to get the current * heading and location. * * @param orientationManager the instance of {@code OrientationManager} that this view will use */ public void setOrientationManager(OrientationManager orientationManager) { mOrientation = orientationManager; } /** * Gets the current heading in degrees. * * @return the current heading. */ public float getHeading() { return mHeading; } /** * Sets the current heading in degrees and redraws the compass. If the angle is not between 0 * and 360, it is shifted to be in that range. * * @param degrees the current heading */ public void setHeading(float degrees) { mHeading = MathUtils.mod(degrees, 360.0f); animateTo(mHeading); } /** * Sets the list of nearby places that the compass should display. This list is recalculated * whenever the user's location changes, so that only locations within a certain distance will * be displayed. * * @param places the list of {@code Place}s that should be displayed */ public void setNearbyPlaces(List<Place> places) { mNearbyPlaces = places; } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // The view displays 90 degrees across its width so that one 90 degree head rotation is // equal to one full view cycle. float pixelsPerDegree = getWidth() / 90.0f; float centerX = getWidth() / 2.0f; float centerY = getHeight() / 2.0f; canvas.save(); canvas.translate(-mAnimatedHeading * pixelsPerDegree + centerX, centerY); // In order to ensure that places on a boundary close to 0 or 360 get drawn correctly, we // draw them three times; once to the left, once at the "true" bearing, and once to the // right. for (int i = -1; i <= 1; i++) { drawPlaces(canvas, pixelsPerDegree, i * pixelsPerDegree * 360); } drawCompassDirections(canvas, pixelsPerDegree); canvas.restore(); mPaint.setColor(NEEDLE_COLOR); drawNeedle(canvas, false); drawNeedle(canvas, true); } /** * Draws the compass direction strings (N, NW, W, etc.). * * @param canvas the {@link Canvas} upon which to draw * @param pixelsPerDegree the size, in pixels, of one degree step */ private void drawCompassDirections(Canvas canvas, float pixelsPerDegree) { float degreesPerTick = 360.0f / mDirections.length; mPaint.setColor(Color.WHITE); // We draw two extra ticks/labels on each side of the view so that the // full range is visible even when the heading is approximately 0. for (int i = -2; i <= mDirections.length + 2; i++) { if (MathUtils.mod(i, 2) == 0) { // Draw a text label for the even indices. String direction = mDirections[MathUtils.mod(i, mDirections.length)]; mPaint.getTextBounds(direction, 0, direction.length(), mTextBounds); canvas.drawText(direction, i * degreesPerTick * pixelsPerDegree - mTextBounds.width() / 2, mTextBounds.height() / 2, mPaint); } else { // Draw a tick mark for the odd indices. canvas.drawLine(i * degreesPerTick * pixelsPerDegree, -TICK_HEIGHT / 2, i * degreesPerTick * pixelsPerDegree, TICK_HEIGHT / 2, mTickPaint); } } } /** * Draws the pins and text labels for the nearby list of places. * * @param canvas the {@link Canvas} upon which to draw * @param pixelsPerDegree the size, in pixels, of one degree step * @param offset the number of pixels to translate the drawing operations by in the horizontal * direction; used because place names are drawn three times to get proper wraparound */ private void drawPlaces(Canvas canvas, float pixelsPerDegree, float offset) { if (mOrientation.hasLocation() && mNearbyPlaces != null) { synchronized (mNearbyPlaces) { Location userLocation = mOrientation.getLocation(); double latitude1 = userLocation.getLatitude(); double longitude1 = userLocation.getLongitude(); mAllBounds.clear(); // Loop over the list of nearby places (those within 10 km of the user's current // location), and compute the relative bearing from the user's location to the // place's location. This determines the position on the compass view where the // pin will be drawn. for (Place place : mNearbyPlaces) { double latitude2 = place.getLatitude(); double longitude2 = place.getLongitude(); float bearing = MathUtils.getBearing(latitude1, longitude1, latitude2, longitude2); String name = place.getName(); double distanceKm = MathUtils.getDistance(latitude1, longitude1, latitude2, longitude2); String text = getContext().getResources().getString( R.string.place_text_format, name, mDistanceFormat.format(distanceKm)); // Measure the text and offset the text bounds to the location where the text // will finally be drawn. Rect textBounds = new Rect(); mPlacePaint.getTextBounds(text, 0, text.length(), textBounds); textBounds.offsetTo((int) (offset + bearing * pixelsPerDegree + PLACE_PIN_WIDTH / 2 + PLACE_TEXT_MARGIN), canvas.getHeight() / 2 - (int) PLACE_TEXT_HEIGHT); // Extend the bounds rectangle to include the pin icon and a small margin // to the right of the text, for the overlap calculations below. textBounds.left -= PLACE_PIN_WIDTH + PLACE_TEXT_MARGIN; textBounds.right += PLACE_TEXT_MARGIN; // This loop attempts to find the best vertical position for the string by // starting at the bottom of the display and checking to see if it overlaps // with any other labels that were already drawn. If there is an overlap, we // move up and check again, repeating this process until we find a vertical // position where there is no overlap, or when we reach the limit on // overlapping place names. boolean intersects; int numberOfTries = 0; do { intersects = false; numberOfTries++; textBounds.offset(0, (int) -(PLACE_TEXT_HEIGHT + PLACE_TEXT_LEADING)); for (Rect existing : mAllBounds) { if (Rect.intersects(existing, textBounds)) { intersects = true; break; } } } while (intersects && numberOfTries <= MAX_OVERLAPPING_PLACE_NAMES); // Only draw the string if it would not go high enough to overlap the compass // directions. This means some places may not be drawn, even if they're nearby. if (numberOfTries <= MAX_OVERLAPPING_PLACE_NAMES) { mAllBounds.add(textBounds); canvas.drawBitmap(mPlaceBitmap, offset + bearing * pixelsPerDegree - PLACE_PIN_WIDTH / 2, textBounds.top + 2, mPaint); canvas.drawText(text, offset + bearing * pixelsPerDegree + PLACE_PIN_WIDTH / 2 + PLACE_TEXT_MARGIN, textBounds.top + PLACE_TEXT_HEIGHT, mPlacePaint); } } } } } /** * Draws a needle that is centered at the top or bottom of the compass. * * @param canvas the {@link Canvas} upon which to draw * @param bottom true to draw the bottom needle, or false to draw the top needle */ private void drawNeedle(Canvas canvas, boolean bottom) { float centerX = getWidth() / 2.0f; float origin; float sign; // Flip the vertical coordinates if we're drawing the bottom needle. if (bottom) { origin = getHeight(); sign = -1; } else { origin = 0; sign = 1; } float needleHalfWidth = NEEDLE_WIDTH / 2; mPath.reset(); mPath.moveTo(centerX - needleHalfWidth, origin); mPath.lineTo(centerX - needleHalfWidth, origin + sign * (NEEDLE_HEIGHT - 4)); mPath.lineTo(centerX, origin + sign * NEEDLE_HEIGHT); mPath.lineTo(centerX + needleHalfWidth, origin + sign * (NEEDLE_HEIGHT - 4)); mPath.lineTo(centerX + needleHalfWidth, origin); mPath.close(); canvas.drawPath(mPath, mPaint); } /** * Sets up a {@link ValueAnimator} that will be used to animate the compass * when the distance between two sensor events is large. */ private void setupAnimator() { mAnimator.setInterpolator(new LinearInterpolator()); mAnimator.setDuration(250); // Notifies us at each frame of the animation so we can redraw the view. mAnimator.addUpdateListener(new AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animator) { mAnimatedHeading = MathUtils.mod((Float) mAnimator.getAnimatedValue(), 360.0f); invalidate(); } }); // Notifies us when the animation is over. During an animation, the user's head may have // continued to move to a different orientation than the original destination angle of the // animation. Since we can't easily change the animation goal while it is running, we call // animateTo() again, which will either redraw at the new orientation (if the difference is // small enough), or start another animation to the new heading. This seems to produce // fluid results. mAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animator) { animateTo(mHeading); } }); } /** * Animates the view to the specified heading, or simply redraws it immediately if the * difference between the current heading and new heading are small enough that it wouldn't be * noticeable. * * @param end the desired heading */ private void animateTo(float end) { // Only act if the animator is not currently running. If the user's orientation changes // while the animator is running, we wait until the end of the animation to update the // display again, to prevent jerkiness. if (!mAnimator.isRunning()) { float start = mAnimatedHeading; float distance = Math.abs(end - start); float reverseDistance = 360.0f - distance; float shortest = Math.min(distance, reverseDistance); if (Float.isNaN(mAnimatedHeading) || shortest < MIN_DISTANCE_TO_ANIMATE) { // If the distance to the destination angle is small enough (or if this is the // first time the compass is being displayed), it will be more fluid to just redraw // immediately instead of doing an animation. mAnimatedHeading = end; invalidate(); } else { // For larger distances (i.e., if the compass "jumps" because of sensor calibration // issues), we animate the effect to provide a more fluid user experience. The // calculation below finds the shortest distance between the two angles, which may // involve crossing 0/360 degrees. float goal; if (distance < reverseDistance) { goal = end; } else if (end < start) { goal = end + 360.0f; } else { goal = end - 360.0f; } mAnimator.setFloatValues(start, goal); mAnimator.start(); } } } }