Introduction
This is an Android application that, I believe, contains just
slightly more than the minimum necessary feature set to be able to
view .cbz format files.
These features are:
- A "list view"of the .cbz
files on the SD card, as shown in the above image. For each file:
- Show a thumbnail of the first
page of the comic.
- Show the name of the file.
- Allow user to start viewing the
rest of the file.
- A "viewer" to read the
.cbz file.
- Viewer will show one page (or
part of page) at a time.
- Fling gestures are used to move
to the next or previous page.
- Double tap will zoom in on a part
of the page.
- Pinch can be used to zoom in and
out.
- When zoomed in, image can be
scrolled using drag gestures.
- User can set a bookmark (Comic
book & page). When application is initially started, the
application will go to the bookmark.
- A menu, to allow user to set the bookmark, return to the
bookmark, or go to a list view of .cbz files to select a different
comic to view.
In terms of Android features, this code demonstrates how to:
- Enumerate files on a SD card
- Read a zip file.
- Read (and resize) a bitmap from a
file
- Show a bitmap, with zoom, scroll,
pinch zoom, and fling functionality.
- Handle the user changing the
screen orientation between landscape and portrait.
- Use intents to pass data between
activities in an Android application.
- Save user settings to persistent
storage, and retrieve them later.
- Provide a menu.
- Customize the layout of the items
of a ListActivity
- Create a simple dialog.
- Do work in on a background thread using AsyncTask
Warning, this is the second Android application I've written, and
my first Code Project article, so there are probably many things I've
done that could be improved. Feedback is welcomed.
Using the code
If you don't know how to set up Eclipse and the Android SDK, go
here
for instructions.
Download the project, unzip and import into Eclipse. Requires minimum of Android 2.3
Comic Book File Format
There are actually a number of formats for storing comic books.
The simplest (and easiest for us) is
.cbz.
It's a set of image files
(usually PNG or JPEG) that have been packed into a zip archive file.
Each image is a page of the comic.
CbzComic.java in this project handles decoding the contents of
.cbz files. From the preceding .cbz description, a .cbz archive file
can be thought of as an array of Bitmaps. So, the most important
functions of the CbzComic class are "Get Bitmap representing
page N" and "Get number of Bitmaps". These are
implemented by the functions getPage()
and numPages()
respectively.
Reading the contents of a Zip archive file.
Android provides two main classes to read a ZIP file,
ZipInputSteam
, and ZipFile
. ZipFile
provides random read access to a
Zip file. As we want to be able to move both forward and backward,
and even jump to a specific page of the comic, this is the class we
want to use. (ZipInputStream
only allows access to the contents of
the file in a serial fashion, not what we want.)
Using the ZipFile
class is reasonably simple. Each file stored in
the archive has corresponding ZipEntry
. To extract a file from the
archive, calling ZipFile.getInputStream()
with the appropriate
ZipEntry
will return the file as an InputStream
.
There are two ways to get a ZipEntry
.
ZipFile.getEntry(String
entryName)
, will return the
ZipEntry
with the specific name, but
requires you to know the
entryName
in advance. The other way is
ZipFile.entries()
, which returns an enumeration that gives you all
entries.
As we wish to access the files in the zip archive in random order,
the simplest way to achieve this would be to use ZipFile.entries()
to
get all the entries, and place them in an array. Then, to get the
file that represents page 'n' of the comic we'd simply get the
ZipEntry
held in the 'n'th element of the array, and use this to get
the InputStream.
However, as Android is typically used in mobile devices that are
memory constrained, instead of storing the ZipEntries themselves in
an array, the CbzFile
class stores the name of each entry in an
array. Then, when we want a particular page from the archive,
ZipFile.getEntry()
is called with the name to obtain the appropriate
ZipEntry
, which is then used to obtain the InputStream.
Once we have an InputStream
, converting it into a bitmap is
trivial. The BitmapFactory
class's decodeStream()
function does this
for us.
Thus, the most interesting functions in CbzComic are the
constructor, which builds the array of ZipEntry names, and getPage()
,
which does the index to entryName
to ZipEntry
mapping and InputStream
to Bitmap conversion. There is also getPageAsThumbnail()
, which
shows how to get a page's bitmap that has been scaled down. So it
could be used, for example, as a thumbnail on a menu.
public CbzComic(String fileName) {
mFileName = fileName;
try {
mZip = new ZipFile(fileName);
mPages = new ArrayList<string>();
Enumeration<? extends ZipEntry> entries = mZip.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (isImageFile(entry)) {
mPages.add(entry.getName());
}
}
} catch (IOException e) {
Log.e(Globals.TAG, "Error opening file", e);
}
}
public Bitmap getPage(int pageNum) {
Bitmap bitmap = null;
try {
ZipEntry entry = mZip.getEntry(mPages.get(pageNum));
InputStream in = null;
try {
in = mZip.getInputStream(entry);
bitmap = BitmapFactory.decodeStream(in);
} finally {
if (in != null) {
in.close();
}
}
} catch (IOException e) {
Log.e(Globals.TAG, "Error loading bitmap", e);
}
return bitmap;
}</string>
Viewing the pages of a Comic:
The viewing of a comic is between two classes; BitmapView.java
,
and BitmapViewController.java
.
The BitmapView
is responsible for showing the page image and
responding to the user's zoom, pinch and scroll gestures to display
the appropriate part of the selected image.
The BitmapViewController
is responsible for responding to the
users fling gestures, to change the currently selected page in the
BitmapView
. The reason for this division of responsibility is so
that I could, in the future, easily reuse the BitmapView
. e.g. If I
wanted to do a photo album browser, (or a web comic viewer) all that
would be needed is writing a new BitmapViewController that obtains
the correct bitmaps in response to fling gestures.
Linking a BitmapView
and BitmapViewController
together is done
by the following code.
mBitmapView = (BitmapView) findViewById(R.id.comicView);
mBitmapController = new BitmapViewController(mBitmapView, (Activity) this);
mBitmapView.setController(mBitmapController);
The BitmapView
is actually a very simple class. It derives from
view, and overrides on Draw()
to show the currently selected image (or
part thereof) to the user. Getting the View to react to scroll,
fling, and zoom and gestures is slightly complicated because the View
does not receive these gestures as events directly. Instead, its
onTouchEvent()
is called with MotionEvents
, and you need to analyse
these events to determine the gesture(s) the user is making.
However, you can use an android.view.GestureDetector
to do this
analysis work for you. There are three steps involved.
First, create an anonymous class that derives from
android.view.GestureDetector.SimpleOnGestureListener
.
This class has a set of methods that are called when the
GestureDetector
determines a gesture occurs. e.g. onDoubleTap()
,
onScroll()
, etc. For each gesture you want to handle, you override that
function and implement the functionality to handle the gesture.
private SimpleOnGestureListener mGestureListener = new SimpleOnGestureListener() {
@Override
public boolean onDoubleTap(MotionEvent e) {
ZoomInOnPoint(e);
return true;
}
@Override
public void onLongPress(MotionEvent e) {
if (mController != null) {
mController.onLongPress();
}
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
scrollViewport(distanceX, distanceY);
return true;
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (mController != null) {
mController.onFling(e1, e2, velocityX, velocityY);
}
return true;
}
};
Next, you create an android.view.GestureDetector
, and hook it up
to the SimpleOnGestureListener
.
public BitmapView(Context context, AttributeSet attrs) {
super(context, attrs);
mGestureDetector = new GestureDetector(context, mGestureListener);
}
Finally, you override the view's onTouchEvent()
and pass the
MotionEvents
on to the GestureDetector
.
public boolean onTouchEvent(MotionEvent event) {
mGestureDetector.onTouchEvent(event);
return true;
}
A minor complication is that the GestureDetector
does not handle
"pinch to zoom", In order to do that, you need to use a
ScaleGestureDetector
, and its matching
SimpleOnScaleGestureListener
, in addition to the GestureDetector
.
As previously mentioned, the BitmapView
does not directly handle
converting flings to "turn the page" actions, this is done
by the BitmapViewController
. However, as flings are detected by the
GestureDetector
, when they occur, the BitmapView
passes them onto the
BitmapViewController
. There is (yet another) minor issue
in that we want the user to be able to do both scroll and fling
gestures and the GestureDetector
sometimes interprets a small scroll
movement as a fling. Or adds a fling to the end of scroll movement.
So, to avoid a page turn when user is just doing a scroll, we check
that the fling exceeds threshold criteria for length and speed.
Note, the thresholds were determined by experimentation, and may not
be suitable for all users. Ideally, we'd provide settings, so that
each user can adjust the thresholds to a value that works best for
them.
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
int minDistance = mBitmapView.getWidth() / 4;
float minVelocity = minDistance * 6;
float distanceX = Math.abs(e2.getX() - e1.getX()) / 2.0f;
if ((minDistance < distanceX) && (minVelocity < Math.abs(velocityX))) {
if (0 < velocityX) {
backPage();
} else {
forwardPage();
}
}
return false;
}
Beyond setting up the GestureDetectors
, most of the BitmapView
code is keeping track of the area of bitmap that should be shown on
screen, and maths to adjust the area in response to zoom and scroll
requests.
Viewing list of comic book files
ListComicsActivity.java provides the UI that allows a user to to
choose the comic to view. i.e. It provides this UI.
Thus, this class does three tasks:
- Find the available .cbz files.
- Show the found files to the user, in a way that allows the
user to select one of them
- Return the selection to the main activity.
Finding the .cbz files is a cheat. As this is a minimal viewer, it
just lists all the files in the "Downloads" directory on
the SD card. This should really be done via a content provider. (A
possible future feature.) The code to get a list of the files is
isMediaAvailable()
and listComicFiles()
, which load mFileNames
with a
list of the comic book files.
private boolean isMediaAvailable() {
String state = Environment.getExternalStorageState();
if (Environment.MEDIA_MOUNTED.equals(state)) {
return true;
} else {
return Environment.MEDIA_MOUNTED_READ_ONLY.equals(state);
}
}
private void listComicFiles() {
if (!isMediaAvailable()) {
Utility.showToast(this, R.string.sd_card_not_mounted);
} else {
File path = Environment
.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
mRootPath = path.toString();
mFileNames = new ArrayList<string>();
String[] filesInDirectory = path.list();
if (filesInDirectory != null) {
for (String fileName : filesInDirectory) {
mFileNames.add(fileName);
}
}
if (mFileNames.isEmpty()) {
Utility.showToast(this, R.string.no_comics_found);
}
}
}</string>
ListComicsActivity
derives from ListActivity
, and uses the
ListActivity
to provide the UI. For the basics of how to use a
ListActivity
see
this article.
The major additional points of interest in this class are using a
background thread to populate the thumbnail on the menu, and
returning the comic selected by the user to the MainActivity
.
A background thread is used to load the thumbnail because this
operation could potentially take a long time, so should not be run on
the UI thread. This is implemented by the LoadThumbnailsTask
class,
which derives from android.os.AsyncTask
. AsyncTask
is well covered by
this document by Google, so I won't discuss it further.
Returning the comic selected
The ListActivity
is an activity, and we want it to return a
result. So, to get it to appear, it's launched from the main activity
by calling startActivityForResult()
.
private void launchComicList() {
Intent listComicsIntent = new Intent(this, ListComicsActivity.class);
startActivityForResult(listComicsIntent, 0);
}
To return information from ListComicsActivity
, you create an
Intent, add the desired information to the intent, call setResult()
,
and then call finish()
to end ListComicsActivity
and return to the
activity that launched ListComicsActivity
.
OnClickListener readButtonListener = new OnClickListener() {
@Override
public void onClick(View v) {
String fileName = titleToFileName((String) v.getTag());
Intent intent = new Intent();
intent.putExtra(FILENAME_EXTRA, fileName);
setResult(RESULT_OK, intent);
finish();
}
};
When ListComicsActivity
ends, onActivityResult()
in the activity
that launched it is called, with the intent from setResult()
. So, we
override onActivityResult()
and extract the information from the intent.
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (resultCode == RESULT_OK) {
String fileName = data.getStringExtra(ListComicsActivity.FILENAME_EXTRA);
loadComic(fileName, 0);
}
}
Bookmark
The final feature of this application is the ability to set and
restore a bookmark. The most common scenario being, just before
shutting down the application, the user should be able to tell the
application to remember the currently displayed comic and page.
Later, when the application is restarted, it should return to the
comic and page. Bookmark.java
is responsible for saving/loading this
persistent information.
Note, if desired, the application could automatically record the
current position on shutdown by overriding MainActivity.onPause()
.
It could also store multiple bookmarks, one per comic. But to keep
things simple at this time, a bookmark is set by the user selecting
the "set bookmark" menu item.
As detailed by Google,
there are several ways of storing persistent information. The
simplest is Shared Preferences. Here's how the Bookmark saves
and loads the state information using Shared Preferences
public void saveToSharedPreferences(Context context) {
if (!isEmpty()) {
SharedPreferences settings = context.getSharedPreferences(PREFS_NAME,
Context.MODE_PRIVATE);
SharedPreferences.Editor editor = settings.edit();
editor.putString(PREFS_COMIC_NAME, mComicName);
editor.putInt(PREFS_PAGE, mPage);
editor.commit();
}
}
public Bookmark(Context context) {
SharedPreferences settings = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
mComicName = settings.getString(PREFS_COMIC_NAME, "");
mPage = settings.getInt(PREFS_PAGE, -1);
}
In addition to storing state persistently, in order to handle the
screen orientation changing (i.e. going from landscape to portrait
and vice versa), we also need to be able to save the state to a
Bundle. This is because, when the device is rotated, Android expects
you to save the state to bundle. Android then restarts your app,
passing in the bundle, which your app uses to restore its state.
In slightly more detail, when the device is rotated,
onSaveInstanceState()
in you activity is called. You need to
override this function and save any state you need persisted into the
supplied Bundle. In our case, the state information we want is the
comic and page currently being viewed.
After calling onSaveInstanceState
, the OS will change the
orientation and restart your application, calling onCreate()
with the bundle from
onSaveInstanceState()
. Note, onCreate()
is also called when your
application starts. But, when it's starting, bundle is null. Thus,
the standard implementation of onCreate()
should check if the bundle
is null or not. If it's not null, then the app should restore its
state, using the information in the bundle.
Here's how the Bookmark saves and loads state information to a
bundle, note how the code is almost identical to that used for Shared
Preferences. (Oddly, SharedPreferences and Bundles are not related.)
public void save(Bundle outState) {
if (!isEmpty()) {
outState.putString(PREFS_COMIC_NAME, mComicName);
outState.putInt(PREFS_PAGE, mPage);
}
}
public Bookmark(Bundle savedInstanceState) {
mComicName = savedInstanceState.getString(PREFS_COMIC_NAME);
mPage = savedInstanceState.getInt(PREFS_PAGE);
}
And in our main activity, the code is:
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
mBitmapController.getBookmark().save(outState);
}
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mBitmapView = (BitmapView) findViewById(R.id.comicView);
mBitmapController = new BitmapViewController(mBitmapView, (Activity) this);
mBitmapView.setController(mBitmapController);
if (savedInstanceState != null) {
loadComic(new Bookmark(savedInstanceState));
} else {
Bookmark bookmark = new Bookmark(this);
if (bookmark.isEmpty()) {
launchComicList();
} else {
loadComic(bookmark);
}
}
}
Main Activity
MainActivity.java
is, well, the application's main activity. It's
the activity that is first started when the application starts. It
uses the BitmapView as its view, creates the main menu and responds
to user selecting menu actions, and responding to user changing the
screen between landscape and portrait.