2013/02/28

Androidの自作CalendarView

AndroidでCalendarViewを使うにはいくつか方法があります。
  1. Android公式のandroid.widget.CalendarViewを使う
  2. CalendarView 公開しましたのCalendarViewを使う
  3. 自分で作る
1.に関してはAPIが11以上でないと使えないので、2.x系以上をサポートするアプリでは使えません。

2.に関してはページ送り、祝日情報などが含まれていてかなり有用なCalendarViewだと思います。

3.本当に最低限のCalendarViewでいいとか、後で色々細かいところを調整したいとかはこっちの方がいいのかなと思います。

今作っているアプリにCalendarView使いたいなってことで、上記の3件を考慮したのですが、今回作っているアプリに一番しっくりきそうなのは3.の形だったので、


のソースコードを元に作りました。(主にリファクタリングと、少しの調整)
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;

import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Color;
import android.graphics.Typeface;
import android.util.AttributeSet;
import android.view.Gravity;
import android.widget.LinearLayout;
import android.widget.TextView;

import <package_name>.R;

/**
 * 指定した年月日のカレンダーを表示するクラス
 */
public class CalendarView extends LinearLayout {
    @SuppressWarnings("unused")
    private static final String TAG = CalendarView.class.getSimpleName();
    
    private static final int WEEKDAYS = 7;
    private static final int MAX_WEEK = 6;
    
    // 週の始まりの曜日を保持する
    private static final int BIGINNING_DAY_OF_WEEK = Calendar.SUNDAY;
    // 今日のフォント色 
    private static final int TODAY_COLOR = Color.RED;
    // 通常のフォント色
    private static final int DEFAULT_COLOR = Color.DKGRAY;
    // 今週の背景色 
    private static final int TODAY_BACKGROUND_COLOR = Color.LTGRAY;
    // 通常の背景色 
    private static final int DEFAULT_BACKGROUND_COLOR = Color.TRANSPARENT;
    
    // 年月表示部分
    private TextView mTitleView; 
    
    // 週のレイアウト
    private LinearLayout mWeekLayout;
    private ArrayList<Linearlayout> mWeeks = new ArrayList<Linearlayout>();
    
    /**
     * コンストラクタ
     * 
     * @param context context
     */
    public CalendarView(Context context) {
        this(context, null);
    }
    
    /**
     * コンストラクタ
     * 
     * @param context context
     * @param attrs attributeset
     */
    @SuppressLint("SimpleDateFormat")
    public CalendarView(Context context, AttributeSet attrs) {
        super(context, attrs);
        this.setOrientation(VERTICAL);
        
        createTitleView(context);
        createWeekViews(context);
        createDayViews(context);
    }

    /**
     * 年月日表示用のタイトルを生成する
     * 
     * @param context context
     */
    private void createTitleView(Context context) {
        float scaleDensity = context.getResources().getDisplayMetrics().density;
        
        mTitleView = new TextView(context);
        mTitleView.setGravity(Gravity.CENTER_HORIZONTAL); // 中央に表示
        mTitleView.setTextSize((int)(scaleDensity * 14));
        mTitleView.setTypeface(null, Typeface.BOLD); // 太字
        mTitleView.setPadding(0, 0, 0, (int)(scaleDensity * 16));
        
        addView(mTitleView, new LinearLayout.LayoutParams(
            LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
    }

    /**
     * 曜日表示用のビューを生成する
     * 
     * @param context context
     */
    private void createWeekViews(Context context) {
        float scaleDensity = context.getResources().getDisplayMetrics().density;
        // 週表示レイアウト
        mWeekLayout = new LinearLayout(context);
        
        Calendar calendar = Calendar.getInstance();
        calendar.set(Calendar.DAY_OF_WEEK, BIGINNING_DAY_OF_WEEK); // 週の頭をセット
        
        for (int i = 0; i < WEEKDAYS; i++) {
            TextView textView = new TextView(context);
            textView.setGravity(Gravity.RIGHT); // 中央に表示
            textView.setPadding(0, 0, (int)(scaleDensity * 4), 0);
            
            LinearLayout.LayoutParams llp = 
                    new LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT);
            llp.weight = 1;
            
            mWeekLayout.addView(textView, llp);
            
            calendar.add(Calendar.DAY_OF_MONTH, 1);
        }
        addView(mWeekLayout, new LinearLayout.LayoutParams(
            LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
    }

    
    /**
     * 日付表示用のビューを生成する
     * 
     * @param context context
     */
    private void createDayViews(Context context) {
        float scaleDensity = context.getResources().getDisplayMetrics().density;
        
        // カレンダー部 最大6行必要
        for (int i = 0; i < MAX_WEEK; i++) {
            LinearLayout weekLine = new LinearLayout(context);
            mWeeks.add(weekLine);
            
            // 1週間分の日付ビュー作成
            for (int j = 0; j < WEEKDAYS; j++) {
                TextView dayView = new TextView(context);
                dayView.setGravity(Gravity.TOP | Gravity.RIGHT); 
                dayView.setPadding(0, (int)(scaleDensity * 4), (int)(scaleDensity * 4), 0);
                LinearLayout.LayoutParams llp = 
                        new LinearLayout.LayoutParams(0, (int)(scaleDensity * 48));
                llp.weight = 1;
                weekLine.addView(dayView, llp);
            }
            
            this.addView(weekLine, new LinearLayout.LayoutParams(
                LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
        }
    }
    
    /**
     * 年と月を指定して、カレンダーの表示を初期化する
     * 
     * @param year 年の指定
     * @param month 月の指定
     */
    public void set(int year, int month) {
        setTitle(year, month);
        setWeeks();
        setDays(year, month);
    }

    /**
     * 指定した年月日をタイトルに設定する
     * 
     * @param year 年の指定
     * @param month 月の指定
     */
    @SuppressLint("SimpleDateFormat")
    private void setTitle(int year, int month) {
        Calendar targetCalendar = getTargetCalendar(year, month);
        
        // 年月フォーマット文字列
        String formatString = mTitleView.getContext().getString(R.string.format_month_year);
        SimpleDateFormat formatter = new SimpleDateFormat(formatString);
        mTitleView.setText(formatter.format(targetCalendar.getTime()));
    }

    /**
     * 曜日を設定する
     */
    @SuppressLint("SimpleDateFormat")
    private void setWeeks() {
        Calendar week = Calendar.getInstance();
        week.set(Calendar.DAY_OF_WEEK, BIGINNING_DAY_OF_WEEK); // 週の頭をセット
        SimpleDateFormat weekFormatter = new SimpleDateFormat("E"); // 曜日を取得するフォーマッタ
        for (int i = 0; i < WEEKDAYS; i++) {
            TextView textView = (TextView) mWeekLayout.getChildAt(i);
            textView.setText(weekFormatter.format(week.getTime())); // テキストに曜日を表示
            week.add(Calendar.DAY_OF_MONTH, 1);
        }
    }

    /**
     * 日付を設定していくメソッド
     * 
     * @param year 年の指定
     * @param month 月の指定
     */
    private void setDays(int year, int month) {
        Calendar targetCalendar = getTargetCalendar(year, month);
        
        int skipCount = getSkipCount(targetCalendar);
        int lastDay = targetCalendar.getActualMaximum(Calendar.DATE);
        int dayCounter = 1;
        
        Calendar todayCalendar = Calendar.getInstance();
        int todayYear  = todayCalendar.get(Calendar.YEAR);
        int todayMonth = todayCalendar.get(Calendar.MONTH);
        int todayDay   = todayCalendar.get(Calendar.DAY_OF_MONTH);
        
        for (int i = 0; i < MAX_WEEK; i++) {
            LinearLayout weekLayout = mWeeks.get(i);
            weekLayout.setBackgroundColor(DEFAULT_BACKGROUND_COLOR);
            for (int j = 0; j < WEEKDAYS; j++) {
                TextView dayView = (TextView) weekLayout.getChildAt(j);
                
                // 第一週かつskipCountが残っていれば
                if (i == 0 && skipCount > 0) {
                    dayView.setText(" ");
                    skipCount--;
                    continue;
                }
                
                // 最終日より大きければ
                if (lastDay < dayCounter) {
                    dayView.setText(" ");
                    continue;
                }
                
                // 日付を設定
                dayView.setText(String.valueOf(dayCounter));
                
                boolean isToday = todayYear  == year  && 
                                  todayMonth == month && 
                                  todayDay   == dayCounter;
                
                if (isToday) {
                    dayView.setTextColor(TODAY_COLOR); // 赤文字
                    dayView.setTypeface(null, Typeface.BOLD); // 太字
                    weekLayout.setBackgroundColor(TODAY_BACKGROUND_COLOR); // 週の背景グレー
                } else {
                    dayView.setTextColor(DEFAULT_COLOR);
                    dayView.setTypeface(null, Typeface.NORMAL);
                }
                dayCounter++;
            }
        }
    }

    /**
     * カレンダーの最初の空白の個数を求める
     * 
     * @param targetCalendar 指定した月のCalendarのInstance
     * @return skipCount
     */
    private int getSkipCount(Calendar targetCalendar) {
        int skipCount; // 空白の個数
        int firstDayOfWeekOfMonth = targetCalendar.get(Calendar.DAY_OF_WEEK); // 1日の曜日
        if (BIGINNING_DAY_OF_WEEK > firstDayOfWeekOfMonth) {
            skipCount = firstDayOfWeekOfMonth - BIGINNING_DAY_OF_WEEK + WEEKDAYS;
        } else {
            skipCount = firstDayOfWeekOfMonth - BIGINNING_DAY_OF_WEEK;
        }
        return skipCount;
    }

    private Calendar getTargetCalendar(int year, int month) {
        Calendar targetCalendar = Calendar.getInstance();
        targetCalendar.clear(); // カレンダー情報の初期化
        targetCalendar.set(Calendar.YEAR, year);
        targetCalendar.set(Calendar.MONTH, month);
        targetCalendar.set(Calendar.DAY_OF_MONTH, 1);
        return targetCalendar;
    }
}

実際に使うときはこんなかんじで

CalendarView calendarView = (CalendarView) findViewById(R.id.carendar);
calendarView.set(2013, 3-1); // 2013年3月にセット

実際に生成されるViewは参考元とほぼ同じです。

後は、日付部分にListenerつけたり、ViewPagerにセットするなりで色々カスタマイズすれば、独自のカレンダーが作れます。

2013/02/07

SimpleCursorAdapterのListViewが即座に更新されない時に確認すべき箇所

SimpleCursorAdapterを使っているLIstViewが更新されなくて色々調べたので書く。

問題だったのは、ListViewのアイテムをクリックした時にSQLiteの該当データを削除する機能を実装していたんだけど、押しても即座に更新されなくてどーすんのかなーっと思って調べた。

よくあるのは
mAdapter.notifyDataSetChanged();
とかで更新するってあるけどこれはどうもArrayAdapterだけの時っぽい。

SimpleCursorAdapterのListView更新について
とかも試したけど、Cursor#requery()ってのがUIスレッドでの更新処理なので使うなって怒られたので、これもちがうなーと。

で、さらに調べてたら次の記事を見つけた。

CursorAdapterとContentProviderの関係

どうやら正しいContentProviderを作ってないとSimpleCursorAdapterが変更を検知できないらしい。

で、自分の作ったContentProvider見てみると、ContentProvider#query()で次のコードが抜けてた。
cursor.setNotificationUri(getContext().getContentResolver(), uri);
上のコードをqueryBuilderからcursorを取得した後に書いておかないとダメだった。

作るときに参考にしたサンプルにはちゃんと書かれていたので普通に自分のミスでした。

で、修正すると、即座に更新されるようになった。

他にもありそうなのはContentProvider#insert()とかで
getContext().getContentResolver().notifyChange(uri, null);
とかかな。

SimpleCursorAdapterを使っているListViewのアイテム要素にListenerをつける

AndroidでSimpleCursorAdapterを使っているListViewでレイアウトの中にListenerをつけたので、その備忘録として。

やりたかったのは、SimpleCursorAdapterを使っているListViewのItem要素の中に複数のclickできるViewがあり、それぞれのclickで異なる動作をすること。

ポイントは

  • ListViewの拡張
  • SimpleCursorAdapterの拡張

サンプルとして使うItem要素のxmlはこんな感じ
(今回はTextViewにlistenerをつける。
@stringと@dimensは別に定義してるもの)

list_item.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingTop="8dp"
    android:paddingLeft="16dp"
    android:paddingRight="16dp"
    android:paddingBottom="8dp"
    android:orientation="vertical" >
    
    <TextView
        android:id="@+id/name_textview"
        android:layout_alignParentTop="true"
        android:layout_alignParentLeft="true"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="@dimen/text_size_large" />
    
    <TextView
        android:id="@+id/point_textview"
        android:layout_below="@id/name_textview"
        android:layout_alignParentLeft="true"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="@dimen/text_size_small" />
    
    <TextView
        android:id="@+id/delete_textview"
        android:layout_below="@id/name_textview"
        android:layout_alignParentRight="true"
        android:layout_width="48dp"
        android:layout_height="24dp"
        android:gravity="center"
        android:text="@string/textview_label_delete"
        android:textSize="@dimen/text_size_small" />
    
    <View
        android:id="@+id/separator_view_to_left_of_delete"
        android:layout_below="@id/name_textview"
        android:layout_toLeftOf="@id/delete_textview"
        android:layout_width="2dp"
        android:layout_height="24dp"
        android:background="@android:color/darker_gray" />
    
    <TextView
        android:id="@+id/edit_textview"
        android:layout_below="@id/name_textview"
        android:layout_toLeftOf="@id/separator_view_to_left_of_delete"
        android:layout_width="48dp"
        android:layout_height="24dp"
        android:gravity="center"
        android:text="@string/textview_label_edit"
        android:textSize="@dimen/text_size_small" />
    
    <View
        android:id="@+id/separator_view_to_left_of_edit"
        android:layout_below="@id/name_textview"
        android:layout_toLeftOf="@id/edit_textview"
        android:layout_width="2dp"
        android:layout_height="24dp"
        android:background="@android:color/darker_gray" />
    
    <TextView
        android:id="@+id/record_textview"
        android:layout_below="@id/name_textview"
        android:layout_toLeftOf="@id/separator_view_to_left_of_edit"
        android:layout_width="48dp"
        android:layout_height="24dp"
        android:gravity="center"
        android:text="@string/textview_label_record"
        android:textSize="@dimen/text_size_small" />
    
</RelativeLayout>

次にListViewを拡張したMyListView。
/**
 * リスト内にボタンを配置して、ボタンが押された時にonItemClickを通知するListView
 */
public class MyListView extends ListView implements OnClickListener {
    
    /**
     * コンストラクタ
     */
    public MyListView(Context context) {
        super(context);
    }
    
    /**
     * コンストラクタ
     */
    public MyListView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public void onClick(View v) {
        int pos = (Integer)v.getTag();
        this.performItemClick(v, pos, v.getId());
    }
}

次にSimpleCursorAdapterを拡張したMyCursorAdapter
private class MyCursorAdapter extends SimpleCursorAdapter {

        public MyCursorAdapter(Context context, int layout, Cursor c,
                String[] from, int[] to, int flags) {
            super(context, layout, c, from, to, flags);
        }
        
        @Override
        public View getView(int position, View convertView, ViewGroup parent) {
            // viewのセットなどはスーパークラスのメソッドに任せる
            View view = super.getView(position, convertView, parent);
            
            /*
             * それぞれのTextViewにpositionTagと
             * MyListViewのlistenerをつける
             */
            TextView deleteTextView = (TextView)view.findViewById(
                    R.id.delete_textview);
            deleteTextView.setTag(position);
            deleteTextView.setOnClickListener((MyListView)parent);
            
            TextView editTextView = (TextView)view.findViewById(
                    R.id.edit_textview);
            editTextView.setTag(position);
            editTextView.setOnClickListener((MyListView)parent);

            TextView recordTextView = (TextView)view.findViewById(
                    R.id.record_textview);
            recordTextView.setTag(position);
            recordTextView.setOnClickListener((MyListView)parent);
            return view;
        }
    }

これで、アイテム要素の中のonClick()でListViewのonItemClick()が呼ばれるようになるので、
onItemClick()の中でidで処理を切り替えればOK。
        @Override
        public void onItemClick(AdapterView<?> parent, View view, int pos, long id) {

            switch(view.getId()) {
                case R.id.delete_textview:
                    // 消去の時の処理
                    break;
                case R.id.edit_textview:
                    // 編集の時の処理
                    break;
                case R.id.record_textview:
                    // 記録の時の処理
                    break;
                default:
                    // 通常のonItemClick()の時の処理
                    break;
            }
        }

参考にした記事
ListViewの中のボタンのクリックイベントをActivityに通知する

2013/02/06

Androidのテキストサイズはdimens.xmlに書いておく

アプリ作ってる時のレイアウトで結構次のように書く場合が多い。

android:textSize="18sp"

直に書いてもいいんだけど、Googleさんが公式:Typography
 「テキストの大きさは限定したほうがいいよ。一つのアプリでテキストサイズがバラバラだと見づらいしね。Android Frameworkでは12sp、14sp、18sp、22spの4つに限定してる」
って言ってて、俺もGoogleさんに従うかな〜と最近はその4種類だけ使うようにしてます。

で、いっつもレイアウトファイルいじっているわけじゃないので、たまに数値を忘れるんだけど、こうすれば忘れないなーってのを思いついたのでメモ。

dimens.xml

    12sp
    14sp
    18sp
    22sp
あとは
android:textSize="@dimen/text_size_medium"
って書くだけでいい。

別にこの値じゃなくてもいいんだけど、種類は絞った方がいいのでdimens.xmlに書いて限定しておくといいと思う。