Android Wear / WatchFace

Something O’Clock – Part 2

On 1st April 2016 I published Something O’Clock, a watch face app for Android Wear, to Play Store. The app is lighthearted in nature (because of the date of publication), it allows the user to set the time to “beer o’clock”, or “sleep o’clock”, or even “burger o’clock”. Although the app itself is quite lighthearted the code behind it is worthy of study and, in this series we’ll take a look at various aspects of developing custom watch faces for Android Wear. In this article we’ll look at the layout and drawing of the watch face.

round_wear_screenshotThe image shows that the watch face consists of two items of test: the “something” (which can be one of a number of different words), and the “o’clock” (which remains static). These are drawn one above the other, and both are sized to match the width of the displayable area. So before we can actually begin positioning them we need to determine this area, and this is where InsetCalculator comes in – its function is to determine the insets:

public class InsetCalculator {
    private final boolean isRound;
    private final Rect insetBounds;
    private final Rect previousBounds;
    private final int squareInset;

    public static InsetCalculator newInstance(Context context, boolean isRound) {
        Rect insetBounds = new Rect();
        Rect previousBounds = new Rect();
        int squareInset = (int) context.getResources().getDimension(R.dimen.digital_x_offset);
        return new InsetCalculator(isRound, insetBounds, previousBounds, squareInset);
    }

    public InsetCalculator(boolean isRound, Rect insetBounds, Rect previousBounds, int squareInset) {
        this.isRound = isRound;
        this.insetBounds = insetBounds;
        this.previousBounds = previousBounds;
        this.squareInset = squareInset;
    }

    public Rect getInsetBounds(Rect bounds) {
        boolean hasChanged = hasBoundsChanged(bounds);
        if (hasChanged) {
            calculateInsetBounds(bounds);
            previousBounds.set(bounds);
        }
        return insetBounds;
    }

    public boolean hasBoundsChanged(Rect bounds) {
        return !bounds.equals(previousBounds);
    }

    private void calculateInsetBounds(Rect bounds) {
        int inset = getInset(bounds);
        insetBounds.set(bounds);
        insetBounds.inset(inset, inset);
    }

    private int getInset(Rect bounds) {
        if (isRound) {
            return calculateRoundInset(bounds);
        }
        return squareInset;
    }

    private int calculateRoundInset(Rect bounds) {
        double radius = bounds.width() / 2f;
        double shortSide = Math.sqrt((radius * radius) / 2f);
        return (int) (radius - shortSide);
    }
}

So we have some basic checks to determine whether the bounds have changed since we last calculated the insets – let’s save some processor cycles if nothing has changed. When we have a square display things are pretty easy – we just have a fixed inset from the edge which we obtain from a dimen value.

It’s a little more complex for circular displays – consider the following diagram:

inset calculations

The dotted square is the largest possible square which can fit within the circle of the display, and this is what we need to calculate. The inset that we’re interested in is the length of the dotted red line labelled ‘i’. To calculate this we first need to find the length of the solid red line which runs from the edge of the square to the centre of the circle. Pythagoras can help us here. We know the diameter of the circle (it’s the width of the circle bounds), and the radius is half of this. If we draw the blue line ‘R’ (which is the radius) at 45° to the horizontal we get a triangle shown. The dotted green and solid red sides are the same length, and we can determine these by squaring the radius, dividing it by two, and finding the square root of that. If we now subtract this from the radius we get the inset value ‘i’:

i = R – √(R²/2)

That’s precisely what we’re doing in calculateRoundInset(). By applying this inset to each side of the bounding rectangle we get the area denoted by the dotted square.

Now that we know the insets we need to position the two pieces of text within it and TextLayout is responsible for this:

public class TextLayout {
    private static final String[] DEFAULT_TEXT = {"something", "o'clock"};

    private final List<String> lines;
    private final List<Text> texts;
    private final int textColour;

    public static TextLayout newInstance(@ColorInt int textColour) {
        List<String> lines = new ArrayList<gt;Arrays.asList(DEFAULT_TEXT);
        List<Text> texts = new ArrayList<>();
        return new TextLayout(lines, texts, textColour);
    }

    TextLayout(List<String> lines, List<Text> texts, int textColour) {
        this.lines = lines;
        this.texts = texts;
        this.textColour = textColour;
    }

    public void setAntiAlias(boolean antiAlias) {
        for (Text text : texts) {
            text.setAntiAlias(antiAlias);
        }
    }

    public void invalidateLayout() {
        texts.clear();
    }

    public void draw(Canvas canvas, Rect bounds) {
        if (isLayoutInvalid()) {
            build(bounds);
        }
        for (Text text : texts) {
            text.draw(canvas);
        }
    }

    private boolean isLayoutInvalid() {
        return lines.size() != texts.size();
    }

    private void build(Rect bounds) {
        int width = bounds.width();

        TextPositioner[] positioners = constructPositioners(width);
        layout(positioners, bounds);

        for (TextPositioner positioner : positioners) {
            texts.add(positioner.createText());
        }
    }

    private TextPositioner[] constructPositioners(int width) {
        TextPositioner[] positioners = new TextPositioner[lines.size()];
        for (int i = 0; i < lines.size(); i++) {
            String line = lines.get(i);
            positioners[i] = TextPositioner.newInstance(line, width, textColour);
        }
        return positioners;
    }

    private void layout(TextPositioner[] positioners, Rect bounds) {
        int yOffset = bounds.top;
        for (TextPositioner positioner : positioners) {
            positioner.setPosition(bounds.left, yOffset);
            yOffset += positioner.getLineHeight();
        }
        if (yOffset > bounds.bottom) {
            adjustHeight(positioner, bounds, yOffset);
        } else {
            centreVertical(positioner, bounds, yOffset);
        }
    }

    private void adjustHeight(TextPositioner[] positioners, Rect bounds, float yOffset) {
        float scaleFactor = (float) bounds.bottom / yOffset;
        float adjustmentFactor = (1f - scaleFactor) / 2f;
        int xAdjustment = (int) (bounds.width() * adjustmentFactor);
        for (TextPositioner positioner : positioners) {
            positioner.adjustSize(scaleFactor, xAdjustment);
        }
    }

    private void centreVertical(TextPositioner[] positioners, Rect bounds, int yOffset) {
        int yAdjustment = (bounds.bottom - yOffset) / 2;
        for (TextPositioner positioner : positioners) {
            positioner.adjustVerticalPosition(yAdjustment);
        }
    }
}

This class is responsible for positioning and drawing the text elements. We initialise it with a lit of the individual lines of text – as it stands this will always be “something” and “o’clock” but well expand on this later in the series. Ultimately each these will be represented by a Text object which actually draws the text on the Canvas – you can see this in draw(). The key responsibility of TextLayout is to create, position and size those Text objects. Once again we have some caching here to prevent us from re-calculating each time we draw – only when the layout has actually changed either because the bounds have changed (extremely unlikely, but it’s better to plan for all eventualities), and if the text itself changes (which it will later on).

When we calculate the layout we first create a TextPositioner which will perform the calculations for each of the lines and return a correctly sized and positioned Text object which gets stored and can be drawn repeatedly until the layout changes again. This is all done in the build() method. We’ll take a look at how TextPositioner works internally in a moment, but for now all we need to know is that the creation of these performs some initial measuring and initial sizing of text to match the width of the bounding rectangle.

In the layout() method we perform a couple of further passes. The first will position the text elements based upon the text sizes that have already been calculated (the line height retrieved from the positioner is the height of its line – albeit not necessarily the final size). This enables us to position the text elements relative to each other.

The second pass is then to adjust things. If we have exceeded the height of the bounding rectangle, then we need to scale every thing to fit and this is done in adjustHeight(). If we haven’t exceeded the bounding rectangle then we just need to centre everything vertically and centreVertical() does this.

Let’s now take a look at to see how we calculate the sizes:

TextPositioner

final class TextPositioner {
    private static final Typeface NORMAL_TYPEFACE = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL);
    private static final int DEFAULT_TEXT_SIZE = 30;
    private static final float LINE_HEIGHT_FACTOR = 1.1f;
    private final String text;
    private final Paint textPaint;
    private final Rect boundsRect;
    private int xOffset;
    private int yOffset;

    public static TextPositioner newInstance(String text, int width, int textColour) {
        Paint textPaint = createTextPaint(textColour);
        adjustTextSize(text, textPaint, width);
        Rect rect = new Rect();
        textPaint.getTextBounds(text, 0, text.length(), rect);
        return new TextPositioner(text, textPaint, rect);
    }

    private static Paint createTextPaint(int textColour) {
        Paint paint = new Paint();
        paint.setColor(textColour);
        paint.setTypeface(NORMAL_TYPEFACE);
        paint.setAntiAlias(true);
        paint.setTextSize(DEFAULT_TEXT_SIZE);
        return paint;
    }

    private static void adjustTextSize(String text, Paint textPaint, int width) {
        float textWidth = textPaint.measureText(text);
        float scaleFactor = width / textWidth;
        float newSize = textPaint.getTextSize() * scaleFactor;
        textPaint.setTextSize(newSize);
    }

    TextPositioner(String text, Paint textPaint, Rect boundsRect) {
        this.text = text;
        this.textPaint = textPaint;
        this.xOffset = 0;
        this.yOffset = 0;
        this.boundsRect = boundsRect;
    }

    void setPosition(int horizontalOffset, int verticalOffset) {
        xOffset = horizontalOffset;
        yOffset = verticalOffset + getBaselineOffset();
    }

    int getBaselineOffset() {
        Rect newBoundsRect = new Rect();
        textPaint.getTextBounds(text, 0, text.length(), newBoundsRect);
        return Math.abs(newBoundsRect.top);
    }

    int getHeight() {
        return boundsRect.height();
    }

    float getLineHeight() {
        return getHeight() * LINE_HEIGHT_FACTOR;
    }

    Text createText() {
        return new Text(textPaint, text, xOffset, yOffset);
    }

    void adjustSize(float scaleFactor, int xAdjustment) {
        xOffset += xAdjustment;
        float newSize = textPaint.getTextSize() * scaleFactor;
        textPaint.setTextSize(newSize);
        textPaint.getTextBounds(text, 0, text.length(), boundsRect);
    }

    void adjustVerticalPosition(int yAdjustment) {
        yOffset += yAdjustment;
    }
}

When we first construct the TextPositioner we create a Paint object (which will eventually render the text) and give it a default text size. We then call adjustTextSize() which determines how wide the text will be rendered using this Paint object. By knowing the actual size and the required size we can calculate a scaling factor. We then multiply the current text size by this scale factor to determine the text size that we need to draw the text at to get text of the desired width. This then gets set on the Paint object.

Most of the rest of the class is simply getters to return various metrics of the text which TextLayout uses to position everything. The exceptions to this are adjustSize() which is used when the height exceeds to bounding rectangle and TextLayout needs to scale things down down to fit; and adjustVerticalPosition() which is used when we haven’t exceeded the bounds and TextLayout needs to vertically centre things.

Also there is createText() which returns a correctly sized and positioned Text object once all of these calculations have been completed. All that remains is to look at Text itself:

class Text {
    private final Paint textPaint;
    private final String text;
    private final int xOffset;
    private final int yOffset;

    public Text(Paint textPaint, String text, int xOffset, int yOffset) {
        this.textPaint = textPaint;
        this.text = text;
        this.xOffset = xOffset;
        this.yOffset = yOffset;
    }

    public void draw(Canvas canvas) {
        canvas.drawText(text, xOffset, yOffset, textPaint);
    }

    public void setAntiAlias(boolean antiAlias) {
        textPaint.setAntiAlias(antiAlias);
    }
}

That’s pretty simple. It’s effectively an immutable object which draws the text at a given position using the Paint object created by TextPositioner. The only adjustment we can make is to change the anti-alias setting as discussed in the previous article.

Phew! There’s quite a lot there, but we finally have a working watch face:

round_wear_screenshot

So it renders, but it’s totally static. In the next article we’ll look at how we can add a companion app to enable us to change the “something”.

The source code for this article is available here.

Many thanks to Daniele Bonaldo, Sebastiano Poggi, Erik Hellman, Hasan Hosgel, Said Tahsin Dane & Murat Yener – my beta testers.

Get it on Google Play

© 2016, Mark Allison. All rights reserved.

Copyright © 2016 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.