はじめに
2025年4月、Android向けアプリ「東方日めくりカレンダー」をリリースしました。
紹介記事
筆者はプログラミングの経験は結構ありましたが、Android StudioやSwiftを触ったのは今回が初めてでした。
今回作成したアプリはダウンロードしてもホームにアプリアイコンが出ず、ウィジェットのカレンダーのみを提供しています。
結構特殊な仕様をしており、実装でも色々と苦労した経験があるのでこの記事ではそれらについて紹介します。
アプリの機能について
- 旧作から獣王園までの約160人の東方キャラのうち一人が毎日ランダムに表示されます。
- 一部のキャラクターは通常とは異なる姿が表示される事があります。
- 特定の日付は東方記念日に制定されており、対応するキャラの立ち絵が表示されます。
- ウィジェットは自由に変形可能で、縦長レイアウトにも横長レイアウトにも対応しています。
- 文字やキャラクターの表示などをオプションで細かく変える事ができます。
- ウィジェットタップで設定画面が開きます
- 上部のタブを押すことで設定画面と(立ち絵の絵師さんなどを記述した)情報画面が切り替わります
- カレンダーの表示は午前0時から午前6時の間に自動的に更新され、午前0時以降にウィジェットをタップする事でも更新できます。
- 旧作から獣王園までの150人を超える東方のキャラクターがほぼ全員ウィジェットに表示されます。
- 一部のキャラクターは旧作の姿など、一定確率で異なる姿で表示されます。姿を変える確率は設定画面で変更可能です。
- 一部の幻想少女は一定確率で「幽玄の花」の姿で表示されます。
- この姿の彼女たちは何がとは言いませんが豊満になります。巨大だったり爆発的なデカさになり、そりゃもうボインボインになります。
- 特定の日付は東方記念日に制定されています。
- 例えば、9月9日は「チルノの日」であり、記念日を適用した場合はチルノの立ち絵が表示されます。
ウィジェットの作成について
ウィジェットの作成では通常のAndroidアプリと同様にAndroid Studioが必要で、基本的にswiftとxmlで実装を行います。
しかし AppWidgetProviderInfo XML
と AppWidgetProvider クラス
の実装が必要になるなど、通常のアプリ開発とはいくつか異なる点があります。
詳細は https://developer.android.com/develop/ui/views/appwidgets?hl=ja を参照してください。
実装で工夫した点
ここからはkotlinの実装を中心に、実装で工夫した点を紹介します。
特に断りが無い限り、kotlinのコードは AppWidgetProvider クラス
のコードです。
ウィジェットをタップした時の挙動の実装
ウィジェットをタップした時、状況に応じて以下の2つの挙動のどちらかを取ります。
- その日、カレンダーがまだ更新されていなければカレンダーを更新する
- カレンダーが既に更新されていれば設定画面のアクティビティを開く
タップしたときにアクティビティを開くだけなら以下のコードのように、比較的簡単に実装可能です。
internal fun updateAppWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int
) {
// 省略
val intent = Intent(context, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val views = RemoteViews(context.packageName, R.layout.touhou_calendar_app_widget)
// SettingButtonはウィジェット全体を覆う透明のボタン
views.setOnClickPendingIntent(R.id.SettingButton, pendingIntent)
// 省略
}
しかし、これではカレンダーの更新処理を実装できません。
なので、以下のように onReceive
時にタップ時の動作を実装するようにしました。
private const val TAP_WIDGET = "TAP_WIDGET"
class TouhouCalendarAppWidget : AppWidgetProvider() {
// 省略
override fun onReceive(context: Context?, intent: Intent?) {
super.onReceive(context, intent)
if (context == null || intent == null) return
// ウィジェットを追加しただけでもここまで数回呼ばれる
if (intent.action == TAP_WIDGET)
{
// isPreferenceDateTodayは保存してある日付と今日の日付が同じならtrueを返す
if (isPreferenceDateToday()) {
// 日付が同じ場合はアクティビティを開く
val newActivityIntent = Intent(context, MainActivity::class.java)
newActivityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(newActivityIntent)
} else {
// 日付が異なる場合は日付と表示を更新して、めくった通知も出す
updateAppWidget(context, AppWidgetManager.getInstance(context), intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID))
Toast.makeText(context, R.string.update_app_widget_with_tap, Toast.LENGTH_SHORT).show()
}
}
}
}
internal fun updateAppWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int
) {
// 省略
val intent = Intent(context, TouhouCalendarAppWidget::class.java)
intent.action = TAP_WIDGET
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val views = RemoteViews(context.packageName, R.layout.touhou_calendar_app_widget)
// SettingButtonはウィジェット全体を覆う透明のボタン
views.setOnClickPendingIntent(R.id.SettingButton, pendingIntent)
// 省略
}
onReceive
関数はウィジェットタップ時以外でも呼ばれるのでウィジェットタップ時に TAP_WIDGET
というactionを設定し、タップ時以外は特に何もしないように設定しています。
ウィジェットのリサイズを許可し、縦長か横長かに応じてレイアウトも変える
ウィジェットはカスタマイズ性を高くしたかったので、リサイズにも対応しました。
リサイズ機能自体は以下のようなウィジェット用のxmlを用意することで実装できます。
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_widget_description"
android:initialKeyguardLayout="@layout/touhou_calendar_app_widget_landscape"
android:initialLayout="@layout/touhou_calendar_app_widget_landscape"
android:previewImage="@mipmap/ic_launcher"
android:previewLayout="@layout/touhou_calendar_app_widget_landscape"
android:targetCellWidth="3"
android:targetCellHeight="2"
android:minWidth="180dp"
android:minHeight="80dp"
android:minResizeWidth="80dp"
android:minResizeHeight="80dp"
android:maxResizeWidth="650dp"
android:maxResizeHeight="450dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="21600000"
android:widgetCategory="home_screen" />
上記の設定によりウィジェットは縦横ともに最小2セル、最大5セルまで変形できます。
サイズはセル数ではなくdpで指定する必要があります。
同じdpでも端末によってセルのサイズが異なるので、シミュレータを利用してどの端末でも問題なくリサイズできるdp数を特定しました。(ちょっと面倒だった)
ただ、レイアウトが縦長になるとウィジェットの文字が潰れて見にくくなる問題が発生しました。
それを回避するために縦長用のレイアウトを作成しましたが、今度は横長時にキャラの立ち絵が見にくくなる問題が発生しました。
この問題を解決するため、以下のコードで画面サイズに応じてレイアウトを変えるようにしました。
internal fun updateAppWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int
) {
// 省略
val isPortrait = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) <= options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT)
val views = if (isPortrait)
RemoteViews(context.packageName, R.layout.touhou_calendar_app_widget_portrait)
else
RemoteViews(context.packageName, R.layout.touhou_calendar_app_widget_landscape)
// 省略
}
options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
でウィジェットの幅、 options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT)
でウィジェットの高さを取得できます。
幅と高さを比較することで、画像のように適用するレイアウトを変える機能を実装できました。


キャラクターをシルエットで表示する
趣を求める人のために、キャラクターの立ち絵をそのままではなくシルエットで表示可能な機能も提供しています。
以下のコードではキャラクターの立ち絵のBitmapからシルエットの器を作り、器を単色で塗りつぶしたものを表示しています。
// characterBitmapは東方キャラの立ち絵のBitmap
val mask = characterBitmap.scale(characterBitmap.width, characterBitmap.height, false)
val utuwa = createBitmap(characterBitmap.width, characterBitmap.height)
val canvas = Canvas(utuwa)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.setXfermode(PorterDuffXfermode(PorterDuff.Mode.DST_IN))
// getMonthColorは月に応じて異なる色を返す関数
canvas.drawColor(getMonthColor(context, date))
canvas.drawBitmap(mask, 0f, 0f, paint)
paint.setXfermode(null)
views.setImageViewBitmap(R.id.CharacterImage, utuwa)
表示はこのようになります。

また、単色ではなく風景のシルエットを表示する機能も実装しました。
以下のコードではキャラクターの立ち絵のBitmapからシルエットの器を作り、器を風景画像で塗りつぶしたものを表示しています。
// characterBitmapは東方キャラの立ち絵のBitmap
val mask = characterBitmap.scale(characterBitmap.width, characterBitmap.height, false)
val utuwa = createBitmap(characterBitmap.width, characterBitmap.height)
val canvas = Canvas(utuwa)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.setXfermode(PorterDuffXfermode(PorterDuff.Mode.DST_IN))
// silhouetteBitmapは風景画像のBitmap
canvas.drawBitmap(silhouetteBitmap, 0f, 0f, null)
canvas.drawBitmap(mask, 0f, 0f, paint)
paint.setXfermode(null)
views.setImageViewBitmap(R.id.CharacterImage, utuwa)
表示はこのようになります。

タブを切り替えることで設定画面と情報画面を切り替え可能にする
ウィジェットタップでアクティビティが開きます。
アクティビティではウィジェットの設定、絵師さんなどの情報の表示を行いますがUXなどを考慮して画面上部のタブを押すことで画像のように設定画面と情報画面を切り替え可能にしました。


まず、アクティビティのAppCompatActivityは以下のように実装しました。
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val tabs = findViewById<TabLayout>(R.id.tabs)
val pager = findViewById<ViewPager2>(R.id.view_pager)
// ViewPagerとFragmentの紐付け
pager.adapter = SectionsPagerAdapter(this)
// 左右のスワイプを禁止する
pager.isUserInputEnabled = false
// TabLayout/ViewPagerの紐付け
TabLayoutMediator(tabs, pager) {
tab, position ->
tab.apply {
when (position) {
0 -> {
text = getString(R.string.title_fragment_settings)
contentDescription = getString(R.string.title_fragment_settings)
icon = null
}
1 -> {
text = getString(R.string.title_fragment_info)
contentDescription = getString(R.string.title_fragment_info)
icon = null
}
}
}
}.attach()
}
}
ここではタブのテキストを設定しています。
レイアウトは以下のようになっています。
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/Theme.TouhouCalendar.AppBarOverlay">
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:minHeight="?actionBarSize"
android:padding="@dimen/appbar_padding"
android:text="@string/app_name"
android:textAppearance="@style/TextAppearance.Widget.AppCompat.Toolbar.Title" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabs"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.tabs.TabItem
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""/>
<com.google.android.material.tabs.TabItem
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""/>
</com.google.android.material.tabs.TabLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
タブを押した時に開かれる画面についてはSectionsPagerAdapterで実装しています。
class SectionsPagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
override fun getItemCount(): Int = 2
override fun createFragment(position: Int): Fragment {
return if (position == 0) SettingsFragment() // 設定画面を開く
else InfoFragment() // 情報画面を開く
}
}
設定画面は以下のようになっています。
ラジオボタンなどの設定UIを押した時の挙動を定義しています。
class SettingsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_settings, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
init(view)
}
private fun init(view: View) {
// スタイルの設定など
val prefs = Utils.getPreference(view.context)
prefCalendarLayout(view, prefs)
}
private fun updateAppWidget(view: View) {
val appWidgetManager = AppWidgetManager.getInstance(view.context)
appWidgetManager.getAppWidgetIds(ComponentName(view.context, TouhouCalendarAppWidget::class.java)).forEach {
com.td12734.touhoucalendar.updateAppWidget(view.context, appWidgetManager, it)
}
}
private fun prefCalendarLayout(view: View, prefs: SharedPreferences) {
val key = this.getString(R.string.Preference_CalendarLayoutSetting)
val group = view.findViewById<RadioGroup>(R.id.CalendarLayoutSetting)
when (prefs.getInt(key, Constants.DEFAULT_CALENDAR_LAYOUT_SETTING))
{
1 -> group.check(R.id.CalendarLayoutSetting_Auto)
2 -> group.check(R.id.CalendarLayoutSetting_Portrait)
3 -> group.check(R.id.CalendarLayoutSetting_Landscape)
}
group.setOnCheckedChangeListener { _: RadioGroup?, id: Int ->
prefs.edit {
when (id) {
R.id.CalendarLayoutSetting_Auto -> putInt(key, 1)
R.id.CalendarLayoutSetting_Portrait -> putInt(key, 2)
R.id.CalendarLayoutSetting_Landscape -> putInt(key, 3)
else -> {
}
}
}
updateAppWidget(view)
}
}
}
情報画面は以下のようになっています。
こちらはUIのスタイルの設定などを除き、ほとんど何もしていません。
class InfoFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_info, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
init(view)
}
private fun init(view: View) {
// スタイルの設定など
}
}
おわりに
上記の実装などを行い、比較的短期間(2か月弱)でリリースまで完了しました。
アプリやウィジェットの開発の感覚はある程度掴めたので、今後も開発に挑戦したいですね。
おまけ:そもそもなぜ、アプリを作ったか
自作ゲームの東方キャラの立ち絵を布教したい、東方キャラを毎日見られるアプリを作りたい、東方記念日を簡単に確認できるアプリを作りたいという3つの理由でアプリを作りました。
まず、筆者はこのアプリを作るまではUnityで東方Projectの二次創作ゲームを作成しており、2024年4月に「東方翠神廻廊」という、(個人的)超大作をリリースしました。
翠神廻廊では大人のお姉さんに成長した東方キャラが登場しますが彼女たちの立ち絵は本当に素晴らしく、ゲームを飛び出して同人誌に載せたり立ち絵のアクリルフィギュアを作ったりしました。
同人グッズのみに飽き足らず、他の物(アプリ)も作りたいなと思ったのがアプリ作成の始まりでした。
ただ、成長した美女の立ち絵だけでアプリを作るのは難しそうだったので、成長していない普通の姿の立ち絵も使おうと思いました。
どうせ使うならマイナーキャラを含めて全キャラを登場させたいなと思いました。
また、成長した美女の立ち絵は東方キャラそれぞれに決められた非公式の記念日である東方記念日に合わせてTwitterで紹介していました。
それを調べるのは少し面倒だったので、スマホを開けば簡単に東方記念日が分かるアプリが個人的に欲しかったです。
上記の3つの要件を満たすアプリを考えた結果、日めくりカレンダーのウィジェットという答えに辿り着きました。