/** Copyright 2015 Tim Engler, Rareventure LLC This file is part of Tiny Travel Tracker. Tiny Travel Tracker is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Tiny Travel Tracker is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Tiny Travel Tracker. If not, see <http://www.gnu.org/licenses/>. */ package com.rareventure.android.widget; import java.util.ArrayList; import java.util.TimerTask; import junit.framework.Assert; import android.content.Context; import android.graphics.Canvas; import android.util.AttributeSet; import android.util.Log; import android.view.MotionEvent; import android.view.View; import com.rareventure.android.HandlerTimer; import com.rareventure.android.Util; import com.rareventure.android.AndroidPreferenceSet.AndroidPreferences; import com.rareventure.android.widget.dial.Strip; import com.rareventure.gps2.GTG; import com.rareventure.gps2.database.TAssert; //TODO 4: settings window for preferences //TODO 4: redo dial movement using Scroller and VelocityTracker public class Dial extends View { /** * The last location the users finger was at */ private float lastX; /** * The last time the dial was updated by the users finger */ private long lastTime; private float momentumPixelsPerMs; /** * The number of ticks in the clients reference frame to pixels on the screen, may be negative * for a reverse layout */ public double ticksPerPixel; //TODO 4: yes, it's weird to have both a double and a long, but I // am worried that a double might not have enough precision for ms (untested) // and a long won't work for subpixel stuff // PERF: We might just use a long and handle it on the client size (like microdivisions // Android uses for long and lat) /** * Whole part of the ticks */ public long ticks; /** * Fractional part of the ticks, will always be from 0 to 1 */ public double ticksFrac; private Preferences prefs = new Preferences(); private HandlerTimer timer; private ArrayList<Strip> strips = new ArrayList<Strip>(); private long startTicks; private long endTicks; private double startTicksFrac; private double endTicksFrac; private long lastMomentumUpdatedTimeMs = System.currentTimeMillis(); private long lastUpdatedViewMs; private Runnable updateStuffTimerTask = new TimerTask() { @Override public void run() { long time = System.currentTimeMillis(); updateTicksForMomentum(time); } }; private int stripFontSizeHeight; private int minHeight; public int preferredHeight; public Dial(Context context, AttributeSet attrs) { super(context, attrs); } public Dial(Context context) { super(context); } private void _init() { timer = new HandlerTimer(updateStuffTimerTask, prefs.dialAnimationDelayMs); } public void addStrip(Strip s) { strips.add(s); s.setDial(this); stripFontSizeHeight += s.pxHeight; // Assert.assertFalse( // "Strip max ticks per pixel is less than ticksPerPixel", // Math.abs(s.maxTicksPerPixel) < Math.abs(ticksPerPixel)); preferredHeight = Math.max(stripFontSizeHeight, minHeight); // Log.d(GTG.TAG,"addStrip called with preferredHeight "+preferredHeight); } public void setStrips(Strip[] strips) { this.strips.clear(); stripFontSizeHeight = 0; for(Strip s : strips) addStrip(s); } public double getTicksPerPixel() { return ticksPerPixel; } protected void setTicksPerPixel(double ticksPerPixel) { this.ticksPerPixel = ticksPerPixel; } public float getPixelLoc(long pos) { return (float) ((pos - ticks - ticksFrac) / ticksPerPixel + getWidth() / 2); } /** * Render the text * * @see android.view.View#onDraw(android.graphics.Canvas) */ @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); //put all the strips in the middle of the dial int y = (preferredHeight - stripFontSizeHeight)/2; for(Strip s : strips) { s.draw(canvas,0, y); y+=s.getHeight(); } } protected void setMinHeight(int minHeight) { this.minHeight = minHeight; preferredHeight = Math.max(stripFontSizeHeight, minHeight); } /** * @see android.view.View#measure(int, int) */ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { setMeasuredDimension(Util.measureWithPreferredSize(widthMeasureSpec, //TODO 3 HACK what should we really do here? preferredHeight + getPaddingLeft() + getPaddingRight()), Util.measureWithPreferredSize(heightMeasureSpec, preferredHeight + getPaddingTop() + getPaddingBottom())); } /** * Regardless if ticksPerPixel is negative or not, this should be the minimum position, * ie it is always true that startTicks < endTicks */ public void setStartTicks(long startTicks) { this.startTicks = startTicks; this.startTicksFrac = 0; } public void setEndTicks(long endTicks) { this.endTicks = endTicks; this.endTicksFrac = 0; } public void setTicks(double ticksWithFrac) { this.ticks = (long) Math.floor(ticksWithFrac); this.ticksFrac = ticksWithFrac - ticks; } public void setStartTicks(double startTicksWithFrac) { this.ticks = this.startTicks = (long) Math.floor(startTicksWithFrac); this.ticksFrac = this.startTicksFrac = startTicksWithFrac - startTicks; } public void setEndTicks(double endTicksWithFrac) { this.endTicks = (long) Math.floor(endTicksWithFrac); this.endTicksFrac = endTicksWithFrac - endTicks; } //TODO 2.3: when reached the time when there are no more gps measurements in the future, // then mark it somehow on the dial that it's useless to go on. Same with the past. @Override public boolean onTouchEvent(MotionEvent event) { long time = System.currentTimeMillis(); if(event.getAction() == MotionEvent.ACTION_DOWN) { timer.stop(); lastX = event.getX(); lastTime = time; return true; } else if(event.getAction() == MotionEvent.ACTION_MOVE) { float pixelMovement = (lastX - event.getX()); // Log.d("GPS","action move, pixelmovement: "+pixelMovement+" time: "+(time-lastTime)); adjustTicks(time, pixelMovement * ticksPerPixel); momentumPixelsPerMs = (float) (pixelMovement / (time - lastTime+1) * prefs.movementMomentumRatio) * (time-lastTime) / (float)prefs.lastMovementTotalMs + momentumPixelsPerMs * (1-(time-lastTime)/ (float)prefs.lastMovementTotalMs); if(momentumPixelsPerMs < 0) momentumPixelsPerMs = -momentumPixelsPerMs * momentumPixelsPerMs; else momentumPixelsPerMs = momentumPixelsPerMs * momentumPixelsPerMs; if(momentumPixelsPerMs > prefs.maxMomentumPixelsPerMs) momentumPixelsPerMs = prefs.maxMomentumPixelsPerMs; if(momentumPixelsPerMs < -prefs.maxMomentumPixelsPerMs) momentumPixelsPerMs = -prefs.maxMomentumPixelsPerMs; lastX = event.getX(); lastTime = time; invalidate(); return true; } else if(event.getAction() == MotionEvent.ACTION_UP) { // Log.d("GPS","action up"); if(momentumPixelsPerMs != 0) { lastMomentumUpdatedTimeMs = System.currentTimeMillis(); timer.start(prefs.dialAnimationDelayMs); } return true; } return false; } /** * * @param movement * @return true if hit the edge of the dial */ private boolean adjustTicks(long time, double movement) { //Note that this automagically always keeps ticksFrac between 0 and 1 regardless // if movement is positive or negative ticksFrac += movement; long adj = (long) Math.floor(ticksFrac); ticksFrac -= adj; ticks += adj; if(ticksFrac < 0 || ticksFrac > 1) TAssert.fail("ticksfrac is out of bounds "+ticksFrac); if(ticks > endTicks || ticks == endTicks && ticksFrac > endTicksFrac) { ticks = endTicks; ticksFrac = endTicksFrac; return true; } else if(ticks < startTicks || ticks == startTicks && ticksFrac < startTicksFrac) { ticks = startTicks; ticksFrac = startTicksFrac; return true; } if(time - lastUpdatedViewMs > prefs.viewAnimationDelayMs) { updateView(time); invalidate(); } return false; } protected void onDetachedFromWindow() { super.onDetachedFromWindow(); if(timer != null) timer.stop(); } //TODO 4: display start and end time //TODO 3: display vertical dial when in horizontal mode? //TODO 3: have advanced option for playing through time? //TODO 4: maybe have skins? Different ways of displaying time, etc. /** * @param time * @return true if the display needs to be updated */ private boolean updateTicksForMomentum(long time) { if(momentumPixelsPerMs == 0) { stopDial(); return false; } //if we've hit the edge of the dial if(adjustTicks(time, (time - lastMomentumUpdatedTimeMs) * momentumPixelsPerMs * ticksPerPixel)) { stopDial(); return true; //we still need to invalidate } float nextMomentumPixelsPerMs; if(momentumPixelsPerMs < 0) nextMomentumPixelsPerMs = momentumPixelsPerMs + (time - lastMomentumUpdatedTimeMs) * prefs.momentumDrainPixelsPerMs2; else nextMomentumPixelsPerMs = momentumPixelsPerMs - (time - lastMomentumUpdatedTimeMs) * prefs.momentumDrainPixelsPerMs2; //if we flipped from negative to positive or vice versa if(nextMomentumPixelsPerMs * momentumPixelsPerMs < 0) momentumPixelsPerMs = 0; else momentumPixelsPerMs = nextMomentumPixelsPerMs; lastMomentumUpdatedTimeMs = time; if(momentumPixelsPerMs == 0) stopDial(); return true; } private void updateView(long time) { if(l != null) { l.posChanged(this); } lastUpdatedViewMs = time; } private void stopDial() { momentumPixelsPerMs = 0; timer.stop(); updateView(System.currentTimeMillis()); postInvalidate(); } private Listener l; public void setListener(Listener l) { this.l = l; } public interface Listener { public void posChanged(Dial dial); } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); _init(); } public static class Preferences implements AndroidPreferences { /** * The number of milliseconds to consider when choosing the momentum for * the dial. If we get a move event and it only covers 1 millsecond, * then its affect will only be 1/<this value> */ public int lastMovementTotalMs = 200; public float maxMomentumPixelsPerMs = 300/1000f; /** * The speed the dial slows down when moving */ public float momentumDrainPixelsPerMs2 = .0001f; /** * The delay of animating the dial */ public long dialAnimationDelayMs = 33; /** * The delay of animating the dial */ public long viewAnimationDelayMs = 99; /** * The speed the dial should move when the user spins it. */ public double movementMomentumRatio = 1; } }