Hituzi Ando's Blog

日々のアプリ開発についてや雑記など。

【iOS】UITableViewにパディングを追加してフローティングボタンと被らないようにする

こんにちは、個人アプリ開発者の@HituziANDOです。

この記事では、UITableViewにパディングを追加し、リストの最後のコンテンツがフローティングボタンなどのUIと被らないようにする方法を紹介しています。

UITableViewは配列データを一覧表示するUIとして、よく使われるViewです。僕が開発しているToDoアプリ『Real TODO』においても、作成したToDoを一覧表示するために使っています。このUITableViewですが、大抵は画面いっぱいに配置することが多く、ときに下画像の左スクリーンショットのように、フローティングボタンなど(ここではメニュー)と最後のコンテンツ(UITableViewCell)が被ってしまい、読みづらくなってしまいます。

※フローティングボタン(Floating Action Button)は、元々、Androidマテリアルデザインで使われているUIでしたが、iOSアプリにおいても比較的多く見かけるUIです。

f:id:hituziando:20200503115439p:plain

今回はこのちょっとした問題を解消するために、右スクリーンショットのように、UITableViewに下パディングを追加することで、スクロールした後に最後のコンテンツがはっきりと読めるようにしたいと思います。

やり方は簡単で、UITableViewのインスタンスに以下の一行を追記するだけでOKです。contentInsetに指定する量は適宜調整してください。

tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 80.0, right: 0)

こうすることで、下パディングが追加された分スクロールすることができるようになるため、最後のコンテンツをフローティングボタンより上にスクロール表示することができます。

ちょっとした問題でも丁寧に解消していくことで、ユーザビリティを上げたいですね。

さいごに

僕が個人開発しているiOSアプリ『Real TODO』はApp Storeで無料で配信中です。気になった方はぜひ使ってみてください!

Real TODO - シンプルなやることリスト

Real TODO - シンプルなやることリスト

  • Hituzi ANDO
  • 仕事効率化
  • 無料
apps.apple.com

FINAL FANTASY Ⅶ REMAKEをクリアして

おはようございます。アプリ開発者の@HituziANDOです。

最近は空き時間をFINAL FANTASY Ⅶ REMAKEに費やしていて、開発の手が止まり気味でした(笑)。

そして、昨晩ついにエンディングを迎えることができました!

総プレイ時間は(NORMALモードで)50時間くらいでした。今作はアクション性が強く、アクションが苦手な僕は、何度も死にまくったので、上手い人がやればもっと早くクリアできると思います。

今作はミッドガル脱出までのストーリーが収録されており、作品全体としては分作になるというのは結構周知の事実で、正直、プレイ前は「ミッドガル脱出なんて序盤の序だし、内容薄そう」とあまり期待していなかったのですが、期待をはるかに超えて面白かったです!!

作品全体を通してすごく丁寧に作り込まれているなという印象です。

次回作はいつ頃発売になるのか、ストーリーは原作通りに進むのかなど気になるところです。原作通りに進むとしたら、完結まで20年くらいかかりそう(笑)。

これこそAIで自動作成できないかな? 原作があってストーリーもわかっているし、今作でゲームシステムやCGなどベースとなるものはあるから、できないこともない気がするけど…。なんとか完結まで見届けたいですね。

ちなみに、今、GWセール中で原作がなんと900円くらいでPlaystation Storeで買えてしまいます。僕もとりあえずポチッときました。

PSでFF7が発売されたのもう23年も前なのですね。感慨深い。

Blue Sketch ver.1.1.5がリリースされました!

こんにちは、個人アプリ開発者の@HituziANDOです。

僕が個人で開発しているiOSアプリ「Blue Sketch」の最新バージョンがリリースされました!

Blue Sketchとは

手書きで自由に、すばやく書けるシンプルなノートアプリです。青いキャンバスに白線のみ書けるという最小限の機能だからこそ、書くことに集中できます。ルーズリーフとボールペンをiPadApple Pencilに置き換えるとイメージしやすいです。

アップデート内容

今回の主なアップデートは以下の3点です。

  1. 読み取り専用モードが追加されました
  2. ドキュメントのコピーができるようになりました
  3. キャンバス画面で直接ドキュメントの新規作成ができるようになりました

1. 読み取り専用モードの追加

読み取り専用モードにすると、編集機能をロックすることができ、誤操作を防止することができます。iPhoneiPadを持っているとき、誤って画面を触ってしまい、知らないうちに不要な線を書き込んでしまうといったことを防げます。

読み取り専用モードにする手順は、以下の通りです。

  1. ドキュメントのタイトルをタップします

f:id:hituziando:20200402184513p:plain

  1. ポップアップが表示されます。編集モードをタップすると、読み取り専用モードに切り替えられます(もう一度タップすると、編集モードに戻ります)

f:id:hituziando:20200402184605p:plain

  1. 完了ボタンをタップします

2. ドキュメントのコピーが可能に

ドキュメントのコピーができるようになりました。キャンバス画面右下のコピーボタンをタップすることで、開いているドキュメントの複製を作成します。

f:id:hituziando:20200402185526p:plain

3. キャンバス画面で直接ドキュメントの新規作成が可能に

今まではフォルダ画面を開かないと新しいドキュメントを追加できませんでしたが、今回のアップデートで、キャンバス画面右下の新規作成ボタンをタップすることで、直接新しいドキュメントを作成できるようになりました。

さいごに

Blue SketchはApp Storeで無料で配信中です。気になった方はぜひインストールしてみてください!

iPhone / iPad

apps.apple.com

Mac

apps.apple.com

Swiftで同じ文字を繰り返す方法

こんばんは、アプリ開発者の@HituziANDOです。

同じ文字を繰り返す方法というと、まず第一にfor文で足し続ける方法を思いつきますが、Swiftでは以下のようにStringのイニシャライザを使ってスマートに書けます。

let str = String(repeating: "a", count: 4)
// => "aaaa"

あまり使う機会はないかもしれませんが、知っておくと地味に便利です。

【Android】LocationManagerを使って位置情報をなるべく取得する

こんにちは、アプリ開発者の@HituziANDOです。

この記事では、AndroidのLocationManagerを使って位置情報をなるべく取得する方法を紹介します。"なるべく"取得するとは、位置情報の精度よりも取得できることそのものに重きを置いています。というのも、先日、LocationManagerを使ったアプリを作っていたのですが、位置情報が全然取得できないという事象に見舞われました。原因はGPS測位のみ有効にしていて、屋内で測位していたため、取得に時間がかかった or 取得できなかったようです。

GPS測位とネットワーク測位

AndroidのLocationManagerにはGPS測位とネットワーク測位の2種類があります。GPS測位とは衛星を使った位置測位であるのに対し、ネットワーク測位とは基地局Wi-Fiアクセスポイントを使った位置測位です。それぞれの特徴を大まかにまとめました。

GPS測位 NW測位
精度 高い 低い
測定時間 遅い 速い
バッテリー消費 多い 少ない

上記より、粗くてもとにかく位置情報を取りたいという場合はNW測位を使い、精度が最重要という場合はGPS測位を使うと良さそうです。ということで、なるべく取得できるようにするためには、NW測位を採用すると良いです。

ところで、この2つの測位方法ですが、同時には片方しか使えないわけではなく、同時に両方使うことができます。以下の実装例は同時に両方を使う例です。

LocationManagerを使った実装

AndroidManifest.xml

AndroidManifest.xmlに以下のパーミッションを追加します。ACCESS_FINE_LOCATIONはGPS測位用、ACCESS_COARSE_LOCATIONはNW測位用です。

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

どちらのパーミッションもProtection level: dangerousなため、ユーザ認可が必要です。

MainActivityクラス

ここでは、MainActivityクラスで位置測位するものとします。(この画面が表示されているときだけ位置測位します)

private const val REQUEST_CODE_PERMISSIONS = 1

class MainActivity : AppCompatActivity() {

    private val locationManager: LocationManagerUtil by lazy {
        val manager = LocationManagerUtil(this)
        manager.listener = locationListener
        manager
    }

    private val locationListener: LocationManagerUtil.Listener = object : LocationManagerUtil.Listener {
        // 位置情報に更新があると呼ばれる
        override fun onLocationUpdated(location: Location) {
            Log.d("""
                provider: ${location.provider} 
                lat: ${location.latitude} 
                lon: ${location.longitude} 
                acc: ${location.accuracy}
                """)
        }
    }

    private var isPermissionDenied = false
        set(value) {
            field = value

            if (!value) {
                // ユーザに権限が与えられているならば位置測位開始
                locationManager.start()
            }
            else {
                locationManager.stop()
            }
        }

    override fun onResume() {
        super.onResume()

        isPermissionDenied = !checkSelfPermissions()
    }

    override fun onPause() {
        super.onPause()

        // 位置測位の終了
        locationManager.stop()
    }

    private fun checkSelfPermissions(): Boolean {
        val deniedPermissions = arrayListOf<String>()

        // 位置情報の権限が与えられているか
        if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
            deniedPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION)
        }

        if (deniedPermissions.size > 0) {
            // 権限が無い場合はユーザに認可してもらえるようにリクエスト(ダイアログが表示される)
            ActivityCompat.requestPermissions(this, deniedPermissions.toTypedArray(), REQUEST_CODE_PERMISSIONS)
            return false
        }

        return true
    }

    // requestPermissionsの結果が返ってくる
    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (grantResults.isNotEmpty()) {
                val deniedPermissions = arrayListOf<String>()

                grantResults.forEachIndexed { index, result ->
                    if (result != PackageManager.PERMISSION_GRANTED) {
                        deniedPermissions.add(permissions[index])
                    }
                }

                isPermissionDenied = deniedPermissions.isNotEmpty()

                if (isPermissionDenied) {
                    // TODO: ユーザに権限が与えられなかったときの処理
                }
            }

            return
        }

        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    }
}

ポイントは位置情報の権限をチェックして、ユーザに認可されているならば位置測位を開始するようにしています。もし、権限が与えられていない場合は、権限を要求し、その結果でもって位置測位を開始するか判断しています。

LocationManagerUtilクラスは後述するLocationManagerのラッパークラスです。

LocationManagerUtilクラス

// 位置情報更新の最小間隔
private const val MIN_INTERVAL = 10 * 1000L // [ms]
// 位置情報更新の最小距離
private const val MIN_DISTANCE = 3.0F   // [meter]

class LocationManagerUtil(context: Context) {

    interface Listener {
        fun onLocationUpdated(location: Location)
    }

    var listener: Listener? = null
    var currentLocation: Location? = null
        private set
    private var isStarted = false

    private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager

    private val locationListener: LocationListener = object : LocationListener {

        // GPS測位またはNW測位で位置情報が更新されると呼ばれる
        override fun onLocationChanged(location: Location?) {
            // TODO: 更新時間や精度などからGPS測位結果とNW測位結果のどちらかより良い方を採用する処理

            currentLocation = location

            location?.apply { listener?.onLocationUpdated(this) }
        }

        override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {
        }

        override fun onProviderEnabled(provider: String?) {
        }

        override fun onProviderDisabled(provider: String?) {
        }
    }

    @SuppressLint("MissingPermission")
    fun start() {
        if (!isStarted) {
            isStarted = true

            // GPS測位とNW測位を開始する
            locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, MIN_INTERVAL, MIN_DISTANCE, locationListener)
            locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, MIN_INTERVAL, MIN_DISTANCE, locationListener)
        }
    }

    fun stop() {
        if (isStarted) {
            isStarted = false
            locationManager.removeUpdates(locationListener)
        }
    }
}

GPS測位とNW測位を開始しています。いずれも位置情報が更新されるとLocationListenerのonLocationChangedメソッドが呼ばれます。MIN_INTERVALとMIN_DISTANCEは位置情報が更新される頻度を調整するパラメータです。

上記の実装により、GPS測位できない状況下でもNW測位で救うことができる可能性があります。ただし、GPS測位もNW測位もできる場合、onLocationChangedメソッドが2重で呼ばれるため、どちらかより良い結果を採用するなど、ひと手間加えた方が良いと思います。

さいごに

AndroidのLocationManagerを使って位置情報を取得する方法を紹介しました。ところで、Android公式ドキュメントでは、FusedLocationProviderClientを使う方法が紹介されています。最近はこちらを使った方が良いのかもしれません。

Swiftで点線を描く

f:id:hituziando:20200229103644p:plain

こんにちは、アプリ開発者の@HituziANDOです。

この記事ではSwiftで点線を描く方法を紹介します。Swiftで点線を描くのは簡単です。

まずはコードから↓。下記のコードはUIImageViewを継承したクラスのdraw(_:)メソッドを抜粋したものです。

public override func draw(_ rect: CGRect) {
    UIGraphicsBeginImageContext(self.frame.size)

    guard let context = UIGraphicsGetCurrentContext() else { return }

    // ブレンドモードの設定
    context.setBlendMode(.normal)

    // 線の色の設定
    UIColor.black.setStroke()

    // 点線の設定
    context.setLineDash(phase: 0, lengths: [5.0, 5.0])

    // 線幅の設定
    context.setLineWidth(1.0)

    // 始点
    context.move(to: CGPoint(x: 10.0, y: 10.0))

    // 終点
    context.addLine(to: CGPoint(x: 100.0, y: 100.0))

    // 線を引く
    context.strokePath()

    // UIImageに書き出す
    let image = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()

    // UIImageViewのimageにセット
    self.image = image
}

点線を描く上でのポイントは、setLineDash(phase:, lengths:)メソッドです。このメソッドのlengthsに点線のパターンを指定します。指定の仕方は、[実線の長さ, 空白の長さ]で指定します。

例えば、[5.0, 5.0]で指定した場合は下図のように描画され、

f:id:hituziando:20200229103644p:plain
lengths=[5.0, 5.0]

[20.0, 10.0]で指定した場合は下図のように描画されます。

f:id:hituziando:20200229110404p:plain
lengths=[20.0, 10.0]

なお、phaseは0のままで良いと思います。

その他の設定はコードのコメントの通りです。ブレンドモードについては公式リファレンス(https://developer.apple.com/documentation/coregraphics/cgblendmode)を一度読んでみて、色々設定を変えながら試してみると良いです。

さいごに

僕が個人開発している手書きノートアプリ「Blue Sketch」でも点線を描くことができます(本記事で使った画像もこのアプリで描いたものです)。

Blue Sketchは、手書きで自由に、すばやく書けるシンプルなノートアプリです。青いキャンバスに白線のみ書けるという最小限の機能だからこそ、書くことに集中できます。

Blue SketchはApp Storeで無料で配信中です。気になった方はぜひインストールしてみてください!

iPhone / iPad

apps.apple.com

Mac

apps.apple.com

App Storeに載せるスクショをカッコよくしてみた

こんにちは、アプリ開発者の@HituziANDOです。

僕が個人で開発しているアプリの一つ「Blue Sketch」のApp Storeに載せるスクショを気合を入れて作り直した話です。

Blue Sketchとは

手書きで自由に、すばやく書けるシンプルなノートアプリです。青いキャンバスに白線のみ書けるという最小限の機能だからこそ、書くことに集中できます。ルーズリーフとボールペンをiPadApple Pencilに置き換えるとイメージしやすいです。

新しいスクショ

さて、新しいスクショは以下の通りです。これを5等分割して掲載しています。

f:id:hituziando:20200222095531j:plain
New

f:id:hituziando:20200222100125j:plain
New

複数のスクショにまたがった感じでイメージを配置する手法はMicrosoft To Doを参考にしています。Microsoft To Doは、もはやスクショではなく完全にイラストですね。昔、スクショと実際のアプリ画面の内容が違うという理由でリジェクトされたことがあるのですが、最近は緩くなったのでしょうか。

ちなみに、以前のBlue Sketchのスクショはこんな感じでした。

f:id:hituziando:20200222101833j:plain
Old

これは酷い(笑)。言い訳をすると、元々Blue Sketchは、昨年のQiitaのAdvent Calendarのネタ用に作ったアプリで、早くリリースすることを第一目標にし、細かいところは手を抜いていました。本来なら目的を果たしたので、これで終了と考えていたのですが、思いの外、インストール数が伸びている(1ヶ月半で300DL近い!)ため、このまま捨て置くのは勿体ないと思い、気合を入れて作り直したという背景です。

ところで、App Store Connectのアナリティクスを見ていると、検索結果一覧からこのBlue Sketchのページを実際に閲覧している人は、わずか6%しかいないことが分かりました。ASO対策としても、カッコいいスクショにしたことで、少しでもインストールへつながってくれればと思います。

なお、スクショはSketchを使って作成しています。

さいごに

Blue SketchはApp Storeで無料で配信中です。気になった方はぜひインストールしてみてください!

iPhone / iPad

apps.apple.com

Mac

apps.apple.com