četrtek, 8. november 2012

Android Custom UI: Horizontal Number Spinner

Hello everyone!
I would like to show how to design a custom UI element for Android the "Horizontal Number Spinner".

The final result looks like this:
Background images I have used:

Gray background
Glass background (9-patch)
Arrow

 1.) Create a custom component which extends View and implement GestureDetector.OnGestureListener interface

package si.in2.ui;

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.drawable.Drawable;
import android.util.AttributeSet;
import android.util.Log;
import android.util.TypedValue;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.widget.Scroller;

public class HorizontalNumberSpinner extends View implements
  GestureDetector.OnGestureListener {
 
 public static final int ARROW_BORDER = 10;
 public static final int BOTTOM_BORDER = 10;
 public static final int MARKER_DIFFERENCE = 8;
 public static final int TEXT_BORDER = 10;
 public static final int TEXT_HEIGHT = 10;
 public static final int TOP_BORDER = 10;
 private Bitmap arrowBitmap;
 
 private Paint arrowPaint;
 private Paint fontPaint;
 private Paint markerPaint;
 private Paint backgroundPaint;
 private Paint smallMarkerPaint;
 
 private GestureDetector gestureDetector;
 private OnChangeListener onChangeListener;
 private Scroller scroller;
 
 private Drawable backgroundBitmap;
 private int arrowOffset;
 private int bottomBorder;
 private int currentX;
 private int firstMarkerIndex;
 private String labelFormat;
 
 private int lastMarkerIndex;
 private int markerDifference;
 
 private int markerSpacing;
 private int maxScrollOffset;
 private double maxValue;
 private int minScrollOffset;
 private double minValue;
 
 private int scaleDivisions;
 private double scaleStep;
 private int scrollOffset;
 
 
 private double step;
 private int stepInPixels;
 private int textBorder;
 private int textHeight;
 private int topBorder;
 
 //interface for event handling
 public  interface OnChangeListener {
  public  void onValueChanged(String pString, double pDouble);
 }
 
 public HorizontalNumberSpinner(Context ctx) {
  super(ctx);
  init(ctx);
  
 }

 public HorizontalNumberSpinner(Context ctx,
   AttributeSet pAttributeSet) {
  super(ctx, pAttributeSet);
  init(ctx);
  
 }

 public HorizontalNumberSpinner(Context ctx,
   AttributeSet pAttrSet, int p) {
  super(ctx, pAttrSet, p);
  init(ctx);
  
 }

 
 private void getArrowOffset() {
  this.arrowOffset = (getMeasuredWidth() - this.arrowBitmap.getWidth() - (int) TypedValue
    .applyDimension(1, 10.0F, getResources().getDisplayMetrics()));
 }

 private int getMaxScrollOffset() {
  return (int) Math.ceil((this.maxValue - this.minValue)
    * this.scaleDivisions * this.markerSpacing / this.scaleStep);
 }

 private int getMinScrollOffset() {
  return (int) Math.ceil(this.minValue * this.scaleDivisions
    * this.markerSpacing / this.scaleStep);
 }

 /**
  * init 
  * @param ctx
  */
 private void init(Context ctx) {
  //
  scroller = new Scroller(ctx); //This class encapsulates scrolling
  gestureDetector = new GestureDetector(this);  //Detects various gestures and events using the supplied MotionEvents
  
  topBorder =  (int)TypedValue.applyDimension(1, 10, getResources().getDisplayMetrics());
  bottomBorder = (int) TypedValue.applyDimension(1, 10, getResources().getDisplayMetrics());
  markerDifference = (int) TypedValue.applyDimension(1, 8,getResources().getDisplayMetrics());
  textHeight = (int) TypedValue.applyDimension(1, 10, getResources().getDisplayMetrics());
  textBorder = (int) TypedValue.applyDimension(1, 10, getResources().getDisplayMetrics());
  
  //default values
  minValue = -200000;
  maxValue = 1000000;
  scaleStep = 1000;
  step = 100;
  scaleDivisions = 5; 
  arrowOffset = 200;
  markerSpacing = 16; 
  labelFormat = "%10.3f";
  stepInPixels = (int) ((step *  scaleDivisions *  markerSpacing) / scaleStep);
  maxScrollOffset = getMaxScrollOffset();
  minScrollOffset = getMinScrollOffset();
  
  //Log.i("SPINER", "maxScrollOffset: " + maxScrollOffset);
  //Log.i("SPINER", "minScrollOffset: " + minScrollOffset);
  
  //Here we set the background image
  this.backgroundBitmap = getResources().getDrawable(R.drawable.sp_bg_3);
  
  //copy the arrow bitmap into variable arrowBitmap 
  this.arrowBitmap = BitmapFactory.decodeResource(
    getContext().getResources(), R.drawable.spinner_pointer).copy(
    Bitmap.Config.ARGB_8888, true);
  
  
  //get the arrow offset.
  getArrowOffset();
  
  //Setup paint objects for 
  initPaintObjects();

 }

 private void initPaintObjects() 
 {
  markerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
  markerPaint.setColor(Color.WHITE);
  markerPaint.setStrokeWidth(3);
  markerPaint.setStyle(Paint.Style.FILL);
  
  smallMarkerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
  smallMarkerPaint.setColor(Color.WHITE);
  smallMarkerPaint.setStrokeWidth(1.0F);
  smallMarkerPaint.setStyle(Paint.Style.FILL);
  smallMarkerPaint.setTextAlign(Paint.Align.CENTER);
  smallMarkerPaint.setTextSize(10);
  
  fontPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
  fontPaint.setStyle(Paint.Style.STROKE);
  fontPaint.setColor(Color.WHITE);
  fontPaint.setStrokeWidth(1);
  fontPaint.setAntiAlias(true);
  fontPaint.setTextSize(12);
  fontPaint.setTextAlign(Paint.Align.CENTER);  
  
  backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);

  arrowPaint = new Paint();
  
  }

 
 private int measureHeigth(int height) {
  View.MeasureSpec.getMode(height);
  return View.MeasureSpec.getSize(height);
 }

 private int measureWidth(int width) {
  View.MeasureSpec.getMode(width);
  return View.MeasureSpec.getSize(width);
 }

 private void notifyListener() {

  if (this.onChangeListener != null) {
   double d = getValue();
      //fire event 
   onChangeListener.onValueChanged(String.format(this.getFormat(), d), d);
  }
 }

 private void scrollToSelectableValue() { 
  
  int val = Math.round(this.scrollOffset / this.stepInPixels) * this.stepInPixels;
  scroller.startScroll(scrollOffset, 0, val - this.scrollOffset, 0);
  Log.i("SPINER", "scrollToSelectableValue val="+ val +"," + scrollOffset);
  post(new Runnable() {
   public void run() {
    if (!scroller.isFinished()) {
     postDelayed(this, 20);
     invalidate(); //correct the value on the spinner     
     return; 
    }
   }
  });
 }

 

 @Override
 public boolean onDown(MotionEvent paramMotionEvent) {
  Log.i("SPINER", "onDown: ");
  
  scroller.abortAnimation();
  scrollToSelectableValue();
  
  return true;
 }

 @Override
 public void onDraw(Canvas pCanvas) {
  
  //check if the scroller is not finished
  if (scroller.isFinished() == false) 
  {
   scroller.computeScrollOffset();
   scrollOffset = scroller.getCurrX(); //get the scroller currnet x value
   notifyListener(); 
  }

  this.backgroundBitmap.setBounds(0, 0, getMeasuredWidth(),getMeasuredHeight());
  this.backgroundBitmap.draw(pCanvas);
  
  //calcukate curentx, first and last marker index...
  this.firstMarkerIndex = ((-this.scrollOffset - this.arrowOffset) / this.markerSpacing);
  this.firstMarkerIndex -= (this.scaleDivisions - this.firstMarkerIndex) % this.scaleDivisions;
  this.currentX = (this.arrowOffset + this.markerSpacing * this.firstMarkerIndex + this.scrollOffset);
  this.lastMarkerIndex = ((-this.scrollOffset - this.arrowOffset + getMeasuredWidth()) / this.markerSpacing);
  this.lastMarkerIndex += this.scaleDivisions - this.lastMarkerIndex % this.scaleDivisions;

  int chk = firstMarkerIndex;
  while(true) {
   if (chk > lastMarkerIndex) {
    pCanvas.drawBitmap(arrowBitmap,
      arrowOffset - (arrowBitmap.getWidth() / 2), 0.0F,
      backgroundPaint);
    return;
   }

   if (chk % scaleDivisions == 0) {
    double d =  (chk / scaleDivisions) * scaleStep;
    
    //draw the leading line
    pCanvas.drawLine(currentX, topBorder, currentX,
      getMeasuredHeight() - bottomBorder - textHeight
        - textBorder, markerPaint);
   
     //draw text below the leading line
    pCanvas.drawText(String.format(labelFormat, d), currentX, getMeasuredHeight() - bottomBorder, fontPaint);
    
     currentX +=  markerSpacing;
    
   } else {
    //lines between leading lines
    pCanvas.drawLine(currentX, topBorder, currentX,
      getMeasuredHeight() - bottomBorder - textHeight
        - textBorder - markerDifference,
      smallMarkerPaint);
    currentX +=  markerSpacing;
   }
   
   chk++;
  } 

 }

 @Override
 public boolean onFling(MotionEvent pMotionEvent1,
   MotionEvent pMotionEvent2, float x, float y) {
  Log.i("SPINER", "onFling: " + x + ", " + y );
  scroller.fling(this.scrollOffset, 0, (int) x,
    (int) y, -this.maxScrollOffset,
    -this.minScrollOffset, 0, 0);
  //start a thread and after 20msec verify if scroller is finished then repaint object..
  post(new Runnable() {
   public void run() {
    if (!scroller.isFinished()) {
     invalidate();
     postDelayed(this, 20);
    } else {
     scrollToSelectableValue();

    }
   }
  });
  return false;
 }

 
 protected void onMeasure(int w, int h) {
  Log.i("SPINER", "onMeasure: " + w + ", " + h );
  int i = measureHeigth(w);
  int j = measureWidth(h);
  getArrowOffset();
  setMeasuredDimension(j, i);
 }

 public boolean onScroll(MotionEvent paramMotionEvent1,
   MotionEvent paramMotionEvent2, float x, float y) {
  Log.i("SPINER", "onScroll: " + x + ", " + y );
  this.scrollOffset = (int) (this.scrollOffset - x);

  if (this.scrollOffset < -this.maxScrollOffset) {
   this.scrollOffset = -this.maxScrollOffset;
  }
  invalidate();
  notifyListener();
  return true;

 }

 @Override
 public void onShowPress(MotionEvent pMotionEvent) {
  return;
 }

 @Override
 public boolean onSingleTapUp(MotionEvent pMotionEvent) {
  return false;
 }

 @Override
 public boolean onTouchEvent(MotionEvent pMotionEvent) {
  if ((pMotionEvent.getAction() == MotionEvent.ACTION_UP) && (this.scroller.isFinished()))
   scrollToSelectableValue();
  this.gestureDetector.onTouchEvent(pMotionEvent);
  return true;
 }

 public void setBackgroundBitmap(Drawable pDrawable) {
  this.backgroundBitmap = pDrawable;
 }

 public void setFormat(String format) {
  this.labelFormat = format;
 }

 public void setMaxValue(double mValue) {
  this.maxValue = mValue;
  this.maxScrollOffset = getMaxScrollOffset();
 }

 public void setMinValue(double minValue) {
  this.minValue = minValue;
  this.minScrollOffset = getMinScrollOffset();
  this.maxScrollOffset = getMaxScrollOffset();
 }

 public void setOnChangeListener(OnChangeListener pOnChangeListener) {
  this.onChangeListener = pOnChangeListener;
 }

 public void setScaleDivisions(int i) {
  scaleDivisions = i;
  minScrollOffset = getMinScrollOffset();
  maxScrollOffset = getMaxScrollOffset();
 }

 public void setScaleStep(double d) {
  scaleStep = d;
  step = d /  (2 * scaleDivisions);
  stepInPixels = (int) ((step * scaleDivisions *  markerSpacing) / d);
  minScrollOffset = getMinScrollOffset();
  maxScrollOffset = getMaxScrollOffset();
 }

 public void setValue(double d) {
  scrollOffset = (int) ((-d *  (scaleDivisions * markerSpacing)) / scaleStep);
  invalidate();
  scrollToSelectableValue();
 }
 
 public String getFormat() {
  return this.labelFormat;
 }

 public double getMaxValue() {
  return this.maxValue;
 }

 public double getMinValue() {
  return this.minValue;
 }

 public int getScaleDivisions() {
  return this.scaleDivisions;
 }

 public double getScaleStep() {
  return this.scaleStep;
 }

 public double getStep() {
  return this.step;
 }

 public double getValue() {
  return ( (-scrollOffset) * scaleStep)
    /  (scaleDivisions * markerSpacing);
 }

 @Override
 public void onLongPress(MotionEvent arg0) {
  // TODO Auto-generated method stub
 return;
  
 }
}

2.Android Layout file




    
    
    

    
    



3. HorizontalSpinnerActivity file
In the "main" activity we initialize our HorizontalSpinner with some values:

package si.in2.ui;

import android.app.Activity;
import android.os.Bundle;

import android.widget.LinearLayout;
import android.widget.TextView;

public class HorizontalSpinnerActivity extends Activity  {
    /** Called when the activity is first created. */
 
 HorizontalNumberSpinner spinner;
 LinearLayout layout;
 TextView text;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        text = (TextView)  findViewById(R.id.textView1);
       
        spinner = (HorizontalNumberSpinner)findViewById(R.id.spiner);
        spinner.setMaxValue(10000.0); // set the maximum value
        spinner.setMinValue(-10000.0);// set the minimum value
        spinner.setScaleStep(50.0); // step for 50 values
        spinner.setScaleDivisions(5); // set scale division between numbers
        spinner.setFormat("%.000f"); // set the format displayed below leading line
        spinner.setValue(5000.0); // start at value
        
        //event handling
        spinner.setOnChangeListener(new HorizontalNumberSpinner.OnChangeListener()
        {
          public void onValueChanged(String paramString, double paramDouble)
          {
            text.setText("Value: " + paramDouble);
          }
        });
        
      
    }

 
}


1 komentar: