This codelab is part of the Advanced Android Development training course, developed by the Google Developers Training team. You will get the most value out of this course if you work through the codelabs in sequence.

For complete details about the course, see the Advanced Android Development overview.

Introduction

When you create a custom view and override its onDraw() method, all drawing happens on the UI thread. Drawing on the UI thread puts an upper limit on how long or complex your drawing operations can be, because your app has to complete all its work for every screen refresh.

One option is to move some of the drawing work to a different thread using a SurfaceView.

The following diagram shows a View Hierarchy with a Surface for the views and another separate Surface for the SurfaceView.

What you should already know

You should be able to:

What you'll learn

What you will do

The SurfaceViewExample app lets you search for an Android image on a dark phone screen using a "flashlight."

The following is a screenshot of the SurfaceViewExample app at startup, and after the user has found the Android image by moving around the flashlight.

Additional features:

You are going to build the SurfaceViewExample app from scratch. The app consists of the following three classes:

1.1 Create an app

  1. Create an app using the Empty Activity template. Call the app SurfaceViewExample.
  2. Uncheck Generate Layout File. You do not need a layout file.

1.2 Create the FlashlightCone class

  1. Create a Java class called FlashlightCone.
 public class FlashlightCone {}
  1. Add member variables for x, y, and the radius.
 private int mX;
 private int mY;
 private int mRadius;
  1. Add methods to get values for x, y, and the radius. You do not need any methods to set them.
public int getX() {
    return mX;
}

public int getY() {
    return mY;
}

public int getRadius() {
       return mRadius;
}
  1. Add a constructor with integer parameters viewWidth and viewHeight.
  2. In the constructor, set mX and mY to position the circle at the center of the screen.
  3. Calculate the radius for the flashlight circle to be one third of the smaller screen dimension.
public FlashlightCone(int viewWidth, int viewHeight) {
   mX = viewWidth / 2;
   mY = viewHeight / 2;
   // Adjust the radius for the narrowest view dimension.
   mRadius = ((viewWidth <= viewHeight) ? mX / 3 : mY / 3);
}
  1. Add a public void update() method. The method takes integer parameters newX and newY, and it sets mX to newX and mY to newY.
public void update(int newX, int newY) {
   mX = newX;
   mY = newY;
}

1.3 Create a new SurfaceView class

  1. Create a new Java class and call it GameView.
  2. Let it extend SurfaceView and implement Runnable. Runnable adds a run() method to your class to run its operations on a separate thread.
public class GameView extends SurfaceView implements Runnable {}
  1. Implement methods to add a stub for the only required method, run().
@Override
public void run(){}
  1. Add the stubs for the constructors and have each constructor call init().
public GameView(Context context) {
   super(context);
   init(context);
}

public GameView(Context context, AttributeSet attrs) {
   super(context, attrs);
   init(context);
}

public GameView(Context context, AttributeSet attrs, int defStyleAttr) {
   super(context, attrs, defStyleAttr);
   init(context);
}
  1. Add private init() method and set the mContext member variable to context.
private void init(Context context) {
   mContext = context;
}
  1. In the GameView class, add stubs for the pause() and resume() methods. Later, you will manage your thread from these two methods.

1.4 Finish the MainActivity

  1. In MainActivity, create a member variable for the GameView class.
private GameView mGameView;

In the onCreate() method:

  1. Lock the screen orientation into landscape. Games often lock the screen orientation.
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
  1. Create an instance of GameView.
  2. Set mGameView to completely fill the screen.
  3. Set mGameView as the content view for MainActivity.
mGameView = new GameView(this);
// Android 4.1 and higher simple way to request fullscreen.
mGameView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN);
setContentView(mGameView);
  1. Still in MainActivity, override the onPause() method to also pause the mGameView object. This onPause() method shows an error, because you have not implemented the pause() method in the GameView class.
@Override
protected void onPause() {
   super.onPause();
   mGameView.pause();
}
  1. Override onResume() to resume the mGameView. The onResume() method shows an error, because you have not implemented the resume() method in the GameView.
@Override
protected void onResume() {
   super.onResume();
   mGameView.resume();
}

1.5 Finish the init() method for the GameView class

In the constructor for the GameView class:

  1. Assign the context to mContext.
  2. Get a persistent reference to the SurfaceHolder. Surfaces are created and destroyed by the system while the holder persists.
  3. Create a Paint object and initialize it.
  4. Create a Path to hold drawing instructions. If prompted, import android.graphics.Path.

Here is the code for the init() method.

private void init(Context context) {
   mContext = context;
   mSurfaceHolder = getHolder();
   mPaint = new Paint();
   mPaint.setColor(Color.DKGRAY);
   mPath = new Path();
}
  1. After copy/pasting the code, define the missing member variables.

1.6 Add the setUpBitmap() method to the GameView class

The setUpBitmap() method calculates a random location on the screen for the Android image that the user has to find. You also need a way to calculate whether the user has found the bitmap.

  1. Set mBitmapX and mBitmapY to random x and y positions that fall inside the screen.
  2. Define a rectangular bounding box that contains the Android image.
  3. Define the missing member variables.
private void setUpBitmap() {
   mBitmapX = (int) Math.floor(
           Math.random() * (mViewWidth - mBitmap.getWidth()));
   mBitmapY = (int) Math.floor(
           Math.random() * (mViewHeight - mBitmap.getHeight()));
   mWinnerRect = new RectF(mBitmapX, mBitmapY,
           mBitmapX + mBitmap.getWidth(),
           mBitmapY + mBitmap.getHeight());
}

1.7 Implement the methods to pause and resume the GameView class

The pause() and resume() methods on the GameView are called from the MainActivity when it is paused or resumed. When the MainActivity pauses, you need to stop the GameView thread. When the MainActivity resumes, you need to create a new GameView thread.

  1. Add the pause() and resume() methods using the code below. The mRunning member variable tracks the thread status, so that you do not try to draw when the activity is not running anymore.
public void pause() {
   mRunning = false;
   try {
       // Stop the thread (rejoin the main thread)
       mGameThread.join();
   } catch (InterruptedException e) {
   }
}

public void resume() {
   mRunning = true;
   mGameThread = new Thread(this);
   mGameThread.start();
}
  1. As before, add the missing member variables.

Thread management can become a lot more complex after you have multiple threads in your game. See Sending Operations to Multiple Threads for lessons in thread management.

1.8 Implement the onSizeChanged() method

There are several ways in which to set up the view after the system has fully initialized the view. The onSizeChangedMethod() is called every time the view changes.The view starts out with 0 dimensions. When the view is first inflated, its size changes and onSizeChangedMethod() is called. Unlike in onCreate(), the view's correct dimensions are available.

  1. Get the image of Android on a skateboard from github and add it to your drawable folder, or use a small image of your own choice.
  2. In GameView, override the onSizeChanged() method. Both the new and the old view dimensions are passed as parameters as shown below.
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
   super.onSizeChanged(w, h, oldw, oldh);
}

Inside the onSizeChanged() method:

  1. Store the width and height in member variables mViewWidth and mViewHeight.
mViewWidth = w;
mViewHeight = h;
  1. Create a FlashlightCone and pass in mViewWidth and mViewHeight.
mFlashlightCone = new FlashlightCone(mViewWidth, mViewHeight);
  1. Set the font size proportional to the view height.
mPaint.setTextSize(mViewHeight / 5);
  1. Create a Bitmap and call setupBitmap().
mBitmap = BitmapFactory.decodeResource(
        mContext.getResources(), R.drawable.android);
setUpBitmap();

1.9 Implement the run() method in the GameView class

The interesting stuff, such as drawing and screen refresh synchronization, happens in the run() method. Inside your run() method stub, do the following:

  1. Declare a Canvas canvas variable at the top of the run() method:
Canvas canvas;
  1. Create a loop that only runs while mRunning is true. All the following code must be inside that loop.
while (mRunning) {

}
  1. Check whether there is a valid Surface available for drawing. If not, do nothing.
 if (mSurfaceHolder.getSurface().isValid()) {

All code that follows must be inside this if statement.

  1. Because you will use the flashlight cone coordinates and radius multiple times, create local helper variables inside the if statement.
int x = mFlashlightCone.getX();
int y = mFlashlightCone.getY();
int radius = mFlashlightCone.getRadius();
  1. Lock the canvas.

In an app, with more threads, you must enclose this code in a

try/catch

block to make sure only one thread is trying to write to the

Surface

at a time.

canvas = mSurfaceHolder.lockCanvas();
  1. Save the current canvas state.
canvas.save();
  1. Fill the canvas with white color.
canvas.drawColor(Color.WHITE);
  1. Draw the Skateboarding Android bitmap on the canvas.
 canvas.drawBitmap(mBitmap, mBitmapX, mBitmapY, mPaint);
  1. Add a circle that is the size of the flashlight cone to mPath.
mPath.addCircle(x, y, radius, Path.Direction.CCW);
  1. Set the circle as the clipping path using the DIFFERENCE operator, so that's what's inside the circle is clipped (not drawn).
// The method clipPath(path, Region.Op.DIFFERENCE) was
// deprecated in API level 26. The recommended alternative
// method is clipOutPath(Path), which is currently available
// in API level 26 and higher.
if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
   canvas.clipPath(mPath, Region.Op.DIFFERENCE);
} else {
   canvas.clipOutPath(mPath);
}
  1. Fill everything outside of the circle with black.
canvas.drawColor(Color.BLACK);
  1. Check whether the the center of the flashlight circle is inside the winning rectangle. If so, color the canvas white, redraw the Android image, and draw the winning message.
if (x > mWinnerRect.left && x < mWinnerRect.right
       && y > mWinnerRect.top && y < mWinnerRect.bottom) {
   canvas.drawColor(Color.WHITE);
   canvas.drawBitmap(mBitmap, mBitmapX, mBitmapY, mPaint);
   canvas.drawText(
           "WIN!", mViewWidth / 3, mViewHeight / 2, mPaint);
}
  1. Drawing is finished, so you need to rewind the path, restore the canvas, and release the lock on the canvas.
mPath.rewind();
canvas.restore();
mSurfaceHolder.unlockCanvasAndPost(canvas);
  1. Run your app. It should display a black screen with a white circle at the center of the screen.

1.10 Respond to motion events

For the game to work, your app needs to detect and respond to the user's motions on the screen.

  1. In GameView, override the onTouchEvent() method and update the flashlight position on the ACTION_DOWN and ACTION_MOVE events.
@Override
public boolean onTouchEvent(MotionEvent event) {
   float x = event.getX();
   float y = event.getY();

   // Invalidate() is inside the case statements because there are
   // many other motion events, and we don't want to invalidate
   // the view for those.
   switch (event.getAction()) {
       case MotionEvent.ACTION_DOWN:
           setUpBitmap();
           updateFrame((int) x, (int) y);
           invalidate();
           break;
       case MotionEvent.ACTION_MOVE:
           updateFrame((int) x, (int) y);
           invalidate();
           break;
       default:
           // Do nothing.
   }
   return true;
}
  1. Implement the updateFrame() method called in onTouchEvent() to set the new coordinates of the FlashlightCone.
private void updateFrame(int newX, int newY) {
   mFlashlightCone.update(newX, newY);
}
  1. Run your app and GAME ON!
  2. After you win, tap the screen to play again.

Android Studio project: SurfaceViewExample

The related concept documentation is in 11.2 The SurfaceView class.

Android developer documentation:

This section lists possible homework assignments for students who are working through this codelab as part of a course led by an instructor. It's up to the instructor to do the following:

Instructors can use these suggestions as little or as much as they want, and should feel free to assign any other homework they feel is appropriate.

If you're working through this codelab on your own, feel free to use these homework assignments to test your knowledge.

Build and run an app

Implement the same MemoryGame app that you created in the 11.1 homework, but use a SurfaceView object.

The MemoryGame app hides and reveals "cards" as the user taps on the screen. Use clipping to implement the hide/reveal effect.

Answer these questions

Question 1

What is a SurfaceView?

Question 2

What is the most distinguishing benefit of using a SurfaceView?

Question 3

When should you consider using a SurfaceView? Select up to three.

Submit your app for grading

Guidance for graders

Check that the app has the following features:

To see all the codelabs in the Advanced Android Development training course, visit the Advanced Android Development codelabs landing page.