20200915のSwiftに関する記事は10件です。

異なる階層の View の座標を取得しよう

今回も初学者向けに階層の異なる特定の View の座標を取得したい時に使用する convert() 関数の使い方を簡単に紹介したいと思います。

実際に使ってみる?‍?

下記のような階層の View を参考に convert() メソッドを使って見て座標を取得してみます。

緑色からみた黄色

これは、そもそも緑色の View の中に 黄色の View が含まれている(addSubView()されている)ので、黄色の View の Frame を取得すれば座標が分かります。

let greenToYellowRect = yellowView.frame
print(greenToYellowRect.toString) // (x: 19.0, y: 38.5)

また、toString というプロパティは下記のように CGRect の Extension プロパティとして追加すると独自に値を取得するためのプロパティを追加することができるようになります? 特に iOS 開発ではこの Extension 記法は頻繁に使用するので、どんどん書いて使い方をマスターしましょう!

extension CGRect {
    var toString: String {
        return "(x: \(self.origin.x), y: \(self.origin.y))"
    }
}

赤色からみた黄色

次に、赤色の View から見た黄色の View の座標を取得していきましょう。先ほどと同じように frame を使って座標を取得したいところですが、今回は赤色から見た黄色の座標を取得したいので、frame で取得してしまうと緑色から見た黄色になってしまうので気をつけましょう。

それでは早速、convert()メソッドを使って赤色から見た黄色の座標を取得してみます。

let redToYellowRect = greenView.convert(yellowView.frame, to: redView)
print(redToYellowRect.toString) // (x: 94.5, y: 146.0)

それぞれの View の指定方法は、最後のセクションにまとめてあります。

白色からみた黄色

次に、白色の View からみた黄色の View の座標を取得していきます。コードは下記のようになります。

let whiteToYellowRect = greenView.convert(yellowView.frame, to: self.view)
print(whiteToYellowRect.toString) // (x: 181.5, y: 315.0)

それぞれの View からみた黄色の View の正しい座標が取れるようになりました?

まとめ

convert() メソッドは、どこにどの View を指定するのか忘れがちなので下記のように覚えておくと便利です。

convert(to:) の場合

let redToYellowRect = 座標を知りたいViewの親View.convert(座標を知りたいView.frame, to: 座標変換の基準となるView)

convert(from:) の場合

let redToYellowRect = 座標変換の基準となるView.convert(座標を知りたいView.frame, from: 座標を知りたいViewの親View)

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【1日目】Youtubeアプリ開発日記(iOS) / 各種設定からYoutube API をアプリからリクエスト

概要

iPhoneアプリ開発を行う人のために、教材となればいいなと思い、この記事を書いていきます。
つくっていくアプリは、YoutubeのiPhoneアプリを開発します。

この記事のゴール

  • YoutubeのAPIを使えるようにする
  • iPhoneアプリでYoutubeのAPIをリクエストできるようにする

アプリの使用ライブラリを先に紹介

  • Alamofire (API通信ライブラリ)
  • Moya/RxSwift (Alamofire補完ライブラリ)
  • RxSwift (リアクティブプログラミング)

準備

いきなりハードルが高くなりそうですが、まずはYoutube Data API を登録します。
https://developers.google.com/youtube/v3/getting-started?hl=ja

手順

  1. Googleアカウントで、Google Cloud Console へのアクセス
  2. プロジェクトを作成
  3. API Keyをこちらで作成
    1. API Keyは、アプリで使うのでメモしておきましょう。
  4. Services から、YouTube Data API を検索して、ステータスをオンにする

補足

Google Cloud Consoleで、プロジェクト作成

スクリーンショット 2020-09-15 20.25.22.png
スクリーンショット 2020-09-15 20.24.04.png

API を試してみよう

API Keyを作成できたら、そのKeyを使ってAPIが実際に使えるのかを確認しましょう。
APIは、Youtube動画を検索できるAPI (/youtube/v3/docs/search/list) を使います。

APIの構成

https://www.googleapis.com/youtube/v3/search?part=snippet&q={検索キーワード}&key={自分のAPI KEY}

レスポンス例

{
  "kind": "youtube#searchListResponse",
  "etag": "BaxNpbPgVkyQQVmx4qeEzGdzCTo",
  "nextPageToken": "CAEQAA",
  "regionCode": "JP",
  "pageInfo": {
    "totalResults": 1000000,
    "resultsPerPage": 1
  },
  "items": [
    {
      "kind": "youtube#searchResult",
      "etag": "MkmmQLdB1WA8Icy7XbfCfXQcdBI",
      "id": {
        "kind": "youtube#channel",
        "channelId": "UCZf__ehlCEBPop-_sldpBUQ"
      },
      "snippet": {
        "publishedAt": "2011-07-19T11:31:43Z",
        "channelId": "UCZf__ehlCEBPop-_sldpBUQ",
        "title": "HikakinTV",
        "description": "HikakinTVはヒカキンが日常の面白いものを紹介するチャンネルです。 ◇プロフィール◇ YouTubeにてHIKAKIN、HikakinTV、HikakinGames、HikakinBlogと 4つの ...",
        "thumbnails": {
          "default": {
            "url": "https://yt3.ggpht.com/-NFhw6-eus8Y/AAAAAAAAAAI/AAAAAAAAAAA/rtPbnb9gvAQ/s88-c-k-no-mo-rj-c0xffffff/photo.jpg"
          },
          "medium": {
            "url": "https://yt3.ggpht.com/-NFhw6-eus8Y/AAAAAAAAAAI/AAAAAAAAAAA/rtPbnb9gvAQ/s240-c-k-no-mo-rj-c0xffffff/photo.jpg"
          },
          "high": {
            "url": "https://yt3.ggpht.com/-NFhw6-eus8Y/AAAAAAAAAAI/AAAAAAAAAAA/rtPbnb9gvAQ/s800-c-k-no-mo-rj-c0xffffff/photo.jpg"
          }
        },
        "channelTitle": "HikakinTV",
        "liveBroadcastContent": "upcoming",
        "publishTime": "2011-07-19T11:31:43Z"
      }
    }
  ]
}

Firebaseプロジェクトを設定

Firebaseコンソール にアクセスして、自分のアプリのプロジェクトを作成しましょう。
ドキュメントのSTEP1 ~ STEP5までを行えば、基本的には問題ないですが、Firebaseは親切なので、以下の画像のように順番にやるべきことを指示してくれます。
スクリーンショット 2020-09-15 21.22.43.png

アプリを作成

Xcodeをインストールして、アプリの初期画面を作成するというのは、こちらの記事などを参考にしてみてください。

ライブラリをインストール

Firebaseの初期設定で、すでにcocoapodsでPodfileを作成していると思いますので、cocoapodsを使ってライブラリを追加していきます。Podfileの基本的な使い方はこちらの記事を参考にしてみると良いでしょう。

それでは、実際にPodfileに、前述のライブラリを追加しましょう。

target 'アプリ名' do
  pod 'Firebase/Analytics'
  pod 'Alamofire', '~> 5.2'
  pod 'Kingfisher', '~> 5.0'
  pod 'Moya/RxSwift', '~> 14.0'
end

これで、pod install をしてください。
アプリ.xcworkspaceを開けば、インストールしたライブラリが使える状態になっています。

ようやくコードが書けます!お疲れ様です!

UIViewControllerにAPIリクエストを実装

import UIKit
import Alamofire

class ViewController: UIViewController {
    private let apiKey = "xxxxxxxxx"

    override func viewDidLoad() {
        super.viewDidLoad()

        let keyword = "ヒカキン"
        let urlString = "https://www.googleapis.com/youtube/v3/search?part=snippet&q=\(keyword)&key=\(apiKey)"
        let request = AF.request(urlString)

        request.responseJSON { response in
            print(response)
        }
    }
}

Youtube API の通信

ここでAPI通信処理を担うライブラリAlamofireを使うためにインポートします。

import Alamofire

あとは簡単。↓で、リクエストするURLを生成します。

let keyword = "ヒカキン"
let urlString = "https://www.googleapis.com/youtube/v3/search?part=snippet&q=\(keyword)&key=\(apiKey)"

↓で、APIにリクエストします。

let request = AF.request(urlString)

最後に、受け取ったレスポンスを処理します。
ここでは、レスポンスをログに出力するのみにしています。

request.responseJSON { response in
    print(response)
}

レスポンス

以下のように、コンソールにログが出力されたら成功です。

success({
    etag = "1pYoINxiqsB91hilou77FGlS_1E";
    items =     (
                {
            etag = MkmmQLdB1WA8Icy7XbfCfXQcdBI;
            id =             {
                channelId = "UCZf__ehlCEBPop-_sldpBUQ";
                kind = "youtube#channel";
            };
            kind = "youtube#searchResult";
            snippet =             {
                channelId = "UCZf__ehlCEBPop-_sldpBUQ";
                channelTitle = HikakinTV;
                description = "HikakinTV\U306f\U30d2\U30ab\U30ad\U30f3\U304c\U65e5\U5e38\U306e\U9762\U767d\U3044\U3082\U306e\U3092\U7d39\U4ecb\U3059\U308b\U30c1\U30e3\U30f3\U30cd\U30eb\U3067\U3059\U3002 \U25c7\U30d7\U30ed\U30d5\U30a3\U30fc\U30eb\U25c7 YouTube\U306b\U3066HIKAKIN\U3001HikakinTV\U3001HikakinGames\U3001HikakinBlog\U3068 \Uff14\U3064\U306e ...";
                liveBroadcastContent = upcoming;
                publishTime = "2011-07-19T11:31:43Z";
                publishedAt = "2011-07-19T11:31:43Z";
                thumbnails =                 {
                    default =                     {
                        url = "https://yt3.ggpht.com/-NFhw6-eus8Y/AAAAAAAAAAI/AAAAAAAAAAA/rtPbnb9gvAQ/s88-c-k-no-mo-rj-c0xffffff/photo.jpg";
                    };
                    high =                     {
                        url = "https://yt3.ggpht.com/-NFhw6-eus8Y/AAAAAAAAAAI/AAAAAAAAAAA/rtPbnb9gvAQ/s800-c-k-no-mo-rj-c0xffffff/photo.jpg";
                    };
                    medium =                     {
                        url = "https://yt3.ggpht.com/-NFhw6-eus8Y/AAAAAAAAAAI/AAAAAAAAAAA/rtPbnb9gvAQ/s240-c-k-no-mo-rj-c0xffffff/photo.jpg";
                    };
                };
                title = HikakinTV;
            };
        }
    );
    kind = "youtube#searchListResponse";
    nextPageToken = CAEQAA;
    pageInfo =     {
        resultsPerPage = 1;
        totalResults = 577518;
    };
    regionCode = JP;
})
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOSからQiitaのAPIを呼ぼうとしたら懐かしいエラーに遭遇した。未来の誰かのためにここに記す

iOS懐かしのエラー

昔よく見たエラーにふとした拍子に出くわししました:yum: 1

App Transport Security has blocked a cleartext HTTP (http://) resource load since it is insecure. Temporary exceptions can be configured via your app's Info.plist file.

これはメッセージの通り、HTTP通信をしようとしたときにiOSがブロックしてくる奴です。

QiitaのAPIを呼ぼうとしていた

URLSessionに渡しているurlは間違いなくhttpsのはず……:thinking:

let url = URL(string: "https://qiita.com/api/v2/items/?per_page=3")!
let task = urlSession.dataTask(with: url) { data, response, error in
    ...

エラーメッセージを確認してみる

- Error Domain=NSURLErrorDomain Code=-1022 "The resource could not be loaded because the App Transport Security policy requires the use of a secure connection." UserInfo={NSUnderlyingError=0x6000007b4b40 {Error Domain=kCFErrorDomainCFNetwork Code=-1022 "(null)"}, NSErrorFailingURLStringKey=http://qiita.com/api/v2/items, NSErrorFailingURLKey=http://qiita.com/api/v2/items, NSLocalizedDescription=The resource could not be loaded because the App Transport Security policy requires the use of a secure connection.}

:astonished:?!:astonished:

NSErrorFailingURLKey=http://qiita.com/api/v2/items?per_page=3

なぜだかわかりませんが、URLスキームがhttpに変わっています???

そして、ローカルホストに繋いだりするときと同じように、App Transport Security Settings の Allow Arbitrary Loads を YES に設定すると問題なく結果が返ってきました。

犯人は……?

小一時間ハマったのち、ようやく犯人が見つかりました。2

let url = URL(string: "https://qiita.com/api/v2/items/?per_page=3")!

:rolling_eyes: よく見ると、URLの末尾にスラッシュがついている……? :rolling_eyes:

え?でも、このURLをcurlで叩いても普通にレスポンスが返ってきてたような?

もう一度curlで叩いてみた

$ curl -D - https://qiita.com/api/v2/items/ 
HTTP/2 301 
date: Tue, 15 Sep 2020 12:45:32 GMT
content-type: text/html
content-length: 178
location: http://qiita.com/api/v2/items
server: nginx

<html>
<head><title>301 Moved Permanently</title></head>
<body bgcolor="white">
<center><h1>301 Moved Permanently</h1></center>
<hr><center>nginx</center>
</body>
</html>

:astonished:?!:astonished:

:innocent:なぜかhttpにリダイレクトされています:innocent:3

QiitaのAPIを使うときは、末尾のスラッシュに気をつけましょう

という結論でした:see_no_evil:

let url = URL(string: "https://qiita.com/api/v2/items?per_page=3")!

万が一同じ現象に遭遇した人がいれば、ここに辿り着けますように。


  1. リリース前の開発ではいまでもよく見るかもしれませんね。 

  2. 当初はmacOSのプロジェクトで試していたため、普段触ることのないサンドボックスの設定などを疑っていたため、どハマりしてしまいました。 

  3. そういえば調査の途中でcurl使ったときには、無意識に-Lオプションをつけていたような……:thinking: 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

QiitaのAPIを呼ぶときに末尾にスラッシュをつけるとhttp://~にリダイレクトされるので気をつけましょう

TL;DR

タイトル通り。curlの結果はこちら

iOS懐かしのエラー

昔よく見たエラーにふとした拍子に出くわししました:yum: 1

App Transport Security has blocked a cleartext HTTP (http://) resource load since it is insecure. Temporary exceptions can be configured via your app's Info.plist file.

これはメッセージの通り、HTTP通信をしようとしたときにiOSがブロックしてくる奴です。

QiitaのAPIを呼ぼうとしていた

URLSessionに渡しているurlは間違いなくhttpsのはず……:thinking:

let url = URL(string: "https://qiita.com/api/v2/items/?per_page=3")!
let task = urlSession.dataTask(with: url) { data, response, error in
    ...

エラーメッセージを確認してみる

- Error Domain=NSURLErrorDomain Code=-1022 "The resource could not be loaded because the App Transport Security policy requires the use of a secure connection." UserInfo={NSUnderlyingError=0x6000007b4b40 {Error Domain=kCFErrorDomainCFNetwork Code=-1022 "(null)"}, NSErrorFailingURLStringKey=http://qiita.com/api/v2/items, NSErrorFailingURLKey=http://qiita.com/api/v2/items, NSLocalizedDescription=The resource could not be loaded because the App Transport Security policy requires the use of a secure connection.}

:astonished:?!:astonished:

NSErrorFailingURLKey=http://qiita.com/api/v2/items?per_page=3

なぜだかわかりませんが、URLスキームがhttpに変わっています???

そして、ローカルホストに繋いだりするときと同じように、App Transport Security Settings の Allow Arbitrary Loads を YES に設定すると問題なく結果が返ってきました。

犯人は……?

小一時間ハマったのち、ようやく犯人が見つかりました。2

let url = URL(string: "https://qiita.com/api/v2/items/?per_page=3")!

:rolling_eyes: よく見ると、URLの末尾にスラッシュがついている……? :rolling_eyes:

え?でも、このURLをcurlで叩いても普通にレスポンスが返ってきてたような?

もう一度curlで叩いてみた

$ curl -D - https://qiita.com/api/v2/items/ 
HTTP/2 301 
date: Tue, 15 Sep 2020 12:45:32 GMT
content-type: text/html
content-length: 178
location: http://qiita.com/api/v2/items
server: nginx

<html>
<head><title>301 Moved Permanently</title></head>
<body bgcolor="white">
<center><h1>301 Moved Permanently</h1></center>
<hr><center>nginx</center>
</body>
</html>

:astonished:?!:astonished:

:innocent:なぜかhttpにリダイレクトされています:innocent:3

QiitaのAPIを使うときは、末尾のスラッシュに気をつけましょう

という結論でした:see_no_evil:

let url = URL(string: "https://qiita.com/api/v2/items?per_page=3")!

万が一同じ現象に遭遇した人がいれば、ここに辿り着けますように。


  1. リリース前の開発ではいまでもよく見るかもしれませんね。 

  2. 当初はmacOSのプロジェクトで試していたため、普段触ることのないサンドボックスの設定などを疑っていたため、どハマりしてしまいました。 

  3. そういえば調査の途中でcurl使ったときには、無意識に-Lオプションをつけていたような……:thinking: 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Tabbarの数を調整する方法 (TabBarController)

概要

・Swift5
・Xcode11.3
での検証になります!

こちらは、個人的なメモ書きとして残しております。
参考になった方は「いいね!」をしてもらえると嬉しいです。

実装例

前提条件

・ 追加StoryBoardには、TabbarItemがついていることを想定しています。
・ 5つ目追加したり、削除したりする処理になっています。

追加(1番後ろに追加します)

  if (self.tabBarController?.viewControllers?.count ?? 5) > 4 { return }
  guard let viewControllers = self.tabBarController?.viewControllers  else { return }
  if viewControllers.count > 4 { return }
  let storyboard = UIStoryboard(name: "xxxxxxx", bundle: nil)
  guard let viewController = storyboard.instantiateInitialViewController() else { return }
  self.tabBarController?.viewControllers?.append(viewController)
  // self.tabBarController?.viewControllers? を viewControllersにするとうまく動かないので注意ですm(_ _)m

削除(1番最後の分を削除する)

 guard let viewControllers = self.tabBarController?.viewControllers  else { return }
 if viewControllers.count != 5 { return }
 self.tabBarController?.viewControllers?.removeLast()
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

TabBarControllerの数を調整(変更)する方法

概要

・Swift5
・Xcode 11.3
での検証になります!

こちらは、個人的なメモ書きとして残しております。
参考になった方は「いいね!」をしてもらえると嬉しいです。

実装例

前提条件

・ 追加StoryBoardには、TabbarItemがついていることを想定しています。
・ 5つ目追加したり、削除したりする処理になっています。
・ ViewController内で記載します。

追加(1番後ろに追加します)

  if (self.tabBarController?.viewControllers?.count ?? 5) > 4 { return }
  guard let viewControllers = self.tabBarController?.viewControllers  else { return }
  if viewControllers.count > 4 { return }
  let storyboard = UIStoryboard(name: "xxxxxxx", bundle: nil)
  guard let viewController = storyboard.instantiateInitialViewController() else { return }
  self.tabBarController?.viewControllers?.append(viewController)
  // self.tabBarController?.viewControllers? を viewControllersにするとうまく動かないので注意ですm(_ _)m

削除(1番最後の分を削除する)

 guard let viewControllers = self.tabBarController?.viewControllers  else { return }
 if viewControllers.count != 5 { return }
 self.tabBarController?.viewControllers?.removeLast()
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Swift】UILabelをUITextViewに変更する際の注意点(メモ)

UILabelで作っていたが、後々ハイパーリンクにしたいなどで、
UILabel→UITextViewに変更しなくては。。などというケースの注意点メモ。。。

環境

  • Xcode: 11.3.1
  • Swift5

・UITextViewのスクロールをオフ

オートレイアウトを使用している場合、スクロールがONだと高さが計算されないので、
以下コードを追記

    textView.isScrollEnabled = false

・UITextViewの余白(padding)を削除

実際に文字を表示してみると上下に余白ができてしまう。
以下コードを追記

    textView.textContainerInset = .zero

備考

アプリ公開しました!よろしければインストールお願いします。
とらんぽ

Twitter始めました!よろしければフォローお願いします。
@yajima_tohshu

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

PHPickerViewControllerを使った動画の読み込み方法

iOS14で新しく登場したPHPickerViewControllerにおける動画の読み込み方法

PHPickerViewController

iOS14で新しくPHPickerViewControllerが追加されました。写真許可をユーザーからとる必要なく、また、OS標準の写真アプリとおなじUIで写真・動画選択ができるとても便利なものです。
Apple Documentation

動画の読み込み

PHPickerViewControllerの表示

func addButtonTapped(_ sender: Any) {
    var configuration = PHPickerConfiguration()
    configuration.filter = .videos
    configuration.selectionLimit = 1
    configuration.preferredAssetRepresentationMode = .current <- 動画を読み込む時のキモ
    let phPicker = PHPickerViewController(configuration: configuration)
    phPicker.delegate = self
    present(phPicker, animated: true, completion: nil)
}

動画の読み込み方法

  • NSItemProviderを使います
  • resultsのなかにPHAssetのassetLocalIdentifierが入っていますが、それを用いて PHAsset. fetchAssets(withLocalIdentifiers:options:)とやってしまうと写真許可のアラートが表示されてしまいます。
  • 写真許可がいらなく動画を読み込むためにはNSItemProviderを使います。
  • ここで NSItemProvider.loadFileRepresentation(forTypeIdentifier:completionHandler:) を使います。
  • 先ほどのPHPickerViewControllerの表示のところで PHPickerConfiguration.preferredAssetRepresentationModeを .currentに設定しておくのが大事です。ここで.currentに設定しない場合、システムがどのバージョンの動画を読み込むかの判定に時間がかかるため、数秒のタイムラグが発生します。ここで.currentを指定しておけば、1秒以内に動画を再生させることができます。 (参考: https://developer.apple.com/forums/thread/652695?answerId=629922022#629922022)
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
        picker.dismiss(animated: true, completion: nil)
        guard let provider = results.first?.itemProvider else { return }
        guard let typeIdentifier = provider.registeredTypeIdentifiers.first else { return }

        if provider.hasItemConformingToTypeIdentifier(typeIdentifier) {
            // not provider.loadInPlaceFileRepresentation(forTypeIdentifier: typeIdentifier) { [weak self] (url, success, error) in
            // provider.loadFileRepresentation should be used
            // PHPickerConfiguration.preferredAssetRepresentationMode should be set .current, otherwise loading takes too long
            // https://developer.apple.com/forums/thread/652695?answerId=629922022#629922022
            provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { [weak self] (url, error) in
                if let error = error { print("*** error: \(error)") }
                if let url = url {
                    let asset = AVURLAsset(url: url)
                    // ...
                }
            }
        }
    }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【運動が続かない怠け癖をなくす】今までにない新しいフィットネスアプリを作ってみた

はじめに

初めまして, iOSエンジニアのakaakozこと小澤です。

今回は先月(2020年8月)リリースした周りのユーザーと監視しながら一緒に頑張るフィットネスiOSアプリ『モニトレ』を紹介したいと思います。

今年6月の中旬から開発をスタートして、9月15日にApp Storeにリリースしました。

この記事ではどんなサービスなのか開発に至った背景サービスの仕様と使用したコードについて書いていきます。

モニトレとは?

monitore-top-4-3@1x.png
『モニトレ』は週に設定した筋トレやエクササイズのノルマを達成しながら、周りのユーザーと一緒に頑張るフィットネスアプリです。
(※他ユーザーには自撮りのエクササイズ動画は閲覧出来ません。)

無料で登録&利用出来ます。(GメールまたはAppleIDで登録可)

例えば週に3回のノルマを設定した場合、週に3回自撮りしたエクササイズ動画をアップロードしないとアプリの"ナマケモノ"の欄に記載されてしまう、"辱め"という新しい形のモチベーションでユーザーの運動を継続させるサービスです。

以下の方にオススメのサービスです。
・運動の継続が難しい方、モチベーションが上がらない方
・値段が高いダイエットプログラムに加入せずに痩せたい方
・コロナ渦でジムに行けず、お家でトレーニングをしたい方

ダウンロードリンク:https://apple.co/3izK84n
公式ウェブサイト: https://monitore-f4d84.web.app/

サービスを作った背景

私は前サービス『WalCal』(訪日外国人向け体験型サービス)を含めサービス開発をスタートしてから2年間、ほぼ休みなしでプログラミングをしていました。(週7日、起きている時間はほぼプログラミング)

そんな多忙な日々を送っていたある日、ストレートネックを発症し首を傷めてしまい、1週間ベットから起き上がれない状態になってしまいました。

あまりの痛みで自力で起き上がれなくなってしまったため、28歳にして親から介護を受ける事になってしまいました。(親には感謝です。ありがとう)

それから1週間後、自力で起き上がれる様になりましたが、まだ残っていた首の痛みとめまいからデスクに座って作業が出来ない状況がさらに1週間続きました。

以下の写真がその時に撮ったレントゲンです。(2020年6月3日)

xray_neck.png
医者の診断は日頃の運動・柔軟不足が原因でした。

運動の大切さを分かっていながらも忙しいことを理由に怠け癖がついていました。

あと"1時間後にやる"が"1日後にやる"になり気付いたらそれが1週間続き、運動&ストレッチを全くしない期間がザラにありました。

1日10分すら自らの健康の時間を取れずにストレートネックを発症し、2週間何も作業が出来ず大切な時間を失ってしまいました。

この私が経験した様な、運動が継続的に出来ない怠け癖を解決出来ないかと考え開発に至りました。

仕様

1.周りのユーザーを評価

モニトレ説明2.png
モニトレは週に設定したノルマを達成しないと、アプリの"ナマケモノ"の欄に記載されてしまいます。(達成出来れば"ヤルキモノ"になります。)
1人ではどうしても怠けが出てしまいますので、周りのユーザーと励まし合い、時には喝を入れながら切磋琢磨出来る機能です。

2.ノルマの達成方法は自撮りのエクササイズ動画をアップロード

モニトレ説明1.png

まずはモニトレのエクササイズ動画(スクワット、腕立て、腹筋、ストレッチなどのメニュー)を選んで一緒にエクササイズします。それと同時に自撮りのレコーディングも行っているので、終わったらそのままアップロード出来る仕様となっています。(※他ユーザーには自撮りのエクササイズ動画は閲覧出来ません。)

実際に私がモニトレで筋トレをしている様子です(YouTube)

モニトレのエクササイズ動画ですがYouTubeにアップロードしたものをYouTube Data Apiで引っ張っています。

3.スケジュール機能でエクササイズ管理

モニトレ説明3.png

モニトレはスケジュール機能がついており、日々のエクササイズを管理できます。
メモ帳も使わずに過去のエクササイズ動画や自らのフォームもチェックできるため便利な機能かと思います。

後ほどこの機能の実装方法を紹介します。

スケジュール機能のコードを紹介

開発言語はSwiftとデータベースはFirebaseを使用しています。(Android版も開発予定)
モニトレのスケジュール機能はサードパーティーライブラリのFSCalendarを使用して実装しています。
FSCalendar: https://github.com/WenchaoD/FSCalendar
カレンダー系アプリなどを作る時にものすごい便利なのでオススメです。

Viewの構造は以下の様になっています。
calenderView.png

Step1: FSCalendarをインストール

Cocoapods/CathageでXcodeプロジェクトにFSCalendarをインストール
Podfileを開きFSCalendarのpodを以下の様に入力

use_frameworks!
target '<Your Target Name>' do
    pod 'FSCalendar'
end

Podをインストール

$ pod install

Step2:FSCalendarを実装

//Model
import Foundation
import UIKit
import AVFoundation

struct UploadedVideoInfo {
    let videoUrl: String
    let uploadDate: Date
    let min: Double
    let weekth: Int
    let dateString: String
    let thumbnailImageUrl: String

    init(dictionary: [String: Any]) {
        self.videoUrl = dictionary["videoUrl"] as? String ?? ""
        let secondsFrom1970 = dictionary["uploadDate"] as? Double ?? 0.0
        self.uploadDate = Date(timeIntervalSince1970: secondsFrom1970)
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "yMMMd", options: 0, locale: Locale(identifier: "ja_JP"))
        dateFormatter.timeZone = TimeZone(abbreviation: "Asia/Tokyo")
        let strDate = dateFormatter.string(from: uploadDate)
        self.dateString = strDate
        self.min = dictionary["min"] as? Double ?? 0.0
        self.weekth = dictionary["weekth"] as? Int ?? 0
        self.thumbnailImageUrl = dictionary["thumbnailImageUrl"] as? String ?? ""
    }

    func dateFromComponent(date: Date) -> Int {
        let year = Calendar.current.component(.year, from: date)
        let month = Calendar.current.component(.month, from: date)
        let day = Calendar.current.component(.day, from: date)
        return year + month + day
    }

}
import UIKit
import FSCalendar
import Firebase
import AVFoundation
import Photos

class CalendarController: UICollectionViewController, UICollectionViewDelegateFlowLayout, FSCalendarDelegate, FSCalendarDataSource, FSCalendarDelegateAppearance {

    var allUploadVideos = [UploadedVideoInfo]()
    var todayUploadVideos = [UploadedVideoInfo]()

    private let fsCalendarCellId = "fsCalendarCellId"
    //FSCalenderを定義
    lazy var fsCalendar: FSCalendar = {
        let calender = FSCalendar()
        calender.backgroundColor = #colorLiteral(red: 0.290171057, green: 0.2902101278, blue: 0.2901577652, alpha: 1)
        calender.layer.cornerRadius = 5
        return calender
    }()

    //VideoCollectionControllerはFSCalenarの日付をタップした時に過去のエクササイズを表示させるためのViewController
    lazy var videoCollectionController: VideoCollectionController = {
        let viewController = VideoCollectionController(collectionViewLayout: UICollectionViewFlowLayout())
        return viewController
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        collectionView.backgroundColor = .black

        navigationItem.title = "スケジュール"
         navigationController?.navigationBar.prefersLargeTitles = true
         navigationController?.navigationBar.backgroundColor = .black
         navigationController?.navigationBar.barTintColor = .black
        navigationController?.navigationBar.barTintColor = .black
        navigationController?.navigationBar.largeTitleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
        //FSCalendarの設定
        fsCalendar.register(FSCalendarCell.self, forCellReuseIdentifier: fsCalendarCellId)
        fsCalendar.dataSource = self
        fsCalendar.delegate = self
        fsCalendar.appearance.headerTitleColor = #colorLiteral(red: 0.1152259931, green: 0.8450154662, blue: 0.3753072321, alpha: 1)
        fsCalendar.appearance.weekdayTextColor = #colorLiteral(red: 0.1152259931, green: 0.8450154662, blue: 0.3753072321, alpha: 1)
    //以下のコードで日本語記載にします
        fsCalendar.locale = Locale(identifier: "ja_JP")

        fetchVideoInfo()
        setupFSCalendarView()

    }
    //過去のアップロードを引っ張ってくる関数
    fileprivate func fetchVideoInfo() {

        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "yMMMd", options: 0, locale: Locale(identifier: "ja_JP"))
        dateFormatter.timeZone = TimeZone(abbreviation: "Asia/Tokyo")
        let today = dateFormatter.string(from: Date())
        //let uploadDate = dateFormatter.string(from: vi)
        print("today", today)

        guard let uid = Auth.auth().currentUser?.uid else {return}

          Firestore.firestore().collection("exercise_uploads").document(uid).collection("exercise_uploadId").getDocuments { (snapshot, err) in
              if let err = err {
                  print("failed to fetch video data", err.localizedDescription)
                  return
              }

              snapshot?.documents.forEach({ (document) in
                  let dict = document.data()
                  let uploadedVideoInfo = UploadedVideoInfo(dictionary: dict)

                //Firestoreのデータベースから取ってきた
                self.allUploadVideos.append(uploadedVideoInfo)

                //ここで本日付の動画をフィルターします
                self.todayUploadVideos = self.allUploadVideos.filter({$0.dateString == today})

                DispatchQueue.main.async {
                    print("checking uploadvideos array", self.allUploadVideos)
                    self.videoCollectionController.todayUploadVideos = self.todayUploadVideos
                    self.fsCalendar.reloadData()
                }

              })

          }
    }
    //ビューの配置と制約
    fileprivate func setupFSCalendarView() {
        view.addSubview(fsCalendar)
        fsCalendar.anchor(top: view.safeAreaLayoutGuide.topAnchor, left: view.leftAnchor, bottom: nil, right: view.rightAnchor, paddingTop: 5, paddingLeft: 5, paddingBottom: 0, paddingRight: 5, height: view.frame.height / 2, width: 0)

        view.addSubview(videoCollectionController.view)
        videoCollectionController.view.anchor(top: fsCalendar.bottomAnchor, left: view.leftAnchor, bottom: view.safeAreaLayoutGuide.bottomAnchor, right: view.rightAnchor, paddingTop: 5, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, height: 0, width: 0)
    }

    //FSCalendarのnumberofEventsForメソッドで日付ごとのエクササイズアップロード数を日付ごとの表示
    func calendar(_ calendar: FSCalendar, numberOfEventsFor date: Date) -> Int {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "yMMMd", options: 0, locale: Locale(identifier: "ja_JP"))
        dateFormatter.timeZone = TimeZone(abbreviation: "Asia/Tokyo")
        let calendarDay = dateFormatter.string(from: date)
        print("calendarDay", calendarDay)
        let todayUploadVideos = self.allUploadVideos.filter({$0.dateString == calendarDay})
        return todayUploadVideos.count

    }

    //日付をタップした際に、VideoCollectionController内のCellへ過去のアップロード動画を表示させる関数
    func calendar(_ calendar: FSCalendar, didSelect date: Date, at monthPosition: FSCalendarMonthPosition) {

        self.todayUploadVideos.removeAll()
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "yMMMd", options: 0, locale: Locale(identifier: "ja_JP"))
        dateFormatter.timeZone = TimeZone(abbreviation: "Asia/Tokyo")
        let calendarDay = dateFormatter.string(from: date)

        self.todayUploadVideos = allUploadVideos.filter({$0.dateString == calendarDay})

        DispatchQueue.main.async {
            print("checking uploadvideos array", self.todayUploadVideos)
            //self.videoCollectionController.uploadVideos = self.uploadVideos
            self.videoCollectionController.todayUploadVideos = self.todayUploadVideos
            self.collectionView.reloadData()
        }
    }

}

class VideoCollectionController: UICollectionViewController, UICollectionViewDelegateFlowLayout {

    private let videoCollectionCellId = "videoCollectionCellId"
    private let videoCollectionFooterId = "videoCollectionFooterId"

    var todayUploadVideos: [UploadedVideoInfo]? {
        didSet {
            print("todayUploadVideos", todayUploadVideos)

            DispatchQueue.main.async {
                self.collectionView.reloadData()
            }
        }
    }
    override func viewDidLoad() {
        super.viewDidLoad()

        collectionView.register(VideoCollectionCell.self, forCellWithReuseIdentifier: videoCollectionCellId)
        collectionView.register(VideoCollectionFooter.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: videoCollectionFooterId)

        collectionView.backgroundColor = .black
    }

    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return todayUploadVideos?.count ?? 0
    }

    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: videoCollectionCellId, for: indexPath) as! VideoCollectionCell
        let thumbnailImageUrl = todayUploadVideos?[indexPath.item].thumbnailImageUrl
        cell.thumbnailCustomImageView.loadImage(urlString: thumbnailImageUrl ?? "")
        return cell
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: view.frame.width / 3, height: view.frame.height)
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return 1
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        return 0
    }

    override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        let footer = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: videoCollectionFooterId, for: indexPath) as! VideoCollectionFooter
        footer.label.text = "アップロードした動画はありません"
        return footer
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
        if todayUploadVideos?.count == 0 {
            return CGSize(width: view.frame.width, height: view.frame.height / 8)
        } else {
            return CGSize(width: 0, height: 0)
        }

    }

    override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        print("tapping cell")
        let playVideoController = PlayVideoController(collectionViewLayout: UICollectionViewFlowLayout())
        playVideoController.videoUrl = todayUploadVideos?[indexPath.item].videoUrl
        self.view.window?.rootViewController?.present(playVideoController, animated: true, completion: nil)
    }

}


class VideoCollectionCell: UICollectionViewCell {

    let thumbnailCustomImageView: CustomImageView = {
        let iv = CustomImageView()
        iv.backgroundColor = .black
        iv.contentMode = .scaleAspectFit
        iv.layer.masksToBounds = true
        iv.layer.borderWidth = 0.5
        iv.layer.borderColor = UIColor.white.cgColor
        iv.clipsToBounds = true
        return iv
    }()

    lazy var playButton: UIButton = {
        let button = UIButton(type: .system)
        button.setImage(UIImage(named: "play")?.withRenderingMode(.alwaysOriginal), for: .normal)
        //button.addTarget(self, action: #selector(handlePlay), for: .touchUpInside)
        return button
    }()

    let readyLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        label.text = "Ready?"
        label.font = .boldSystemFont(ofSize: 32)
        label.textColor = .white
        return label
    }()

    var playerLayer: AVPlayerLayer?
    var player: AVPlayer?

    @objc fileprivate func handlePlay() {
        print("handling play")

    }

    override init(frame: CGRect) {
        super.init(frame: frame)

        backgroundColor = #colorLiteral(red: 0.290171057, green: 0.2902101278, blue: 0.2901577652, alpha: 1)

        setupPlayButton()
    }

    fileprivate func setupPlayButton() {

        addSubview(thumbnailCustomImageView)
        thumbnailCustomImageView.anchor(top: topAnchor, left: leftAnchor, bottom: bottomAnchor, right: rightAnchor, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, height: 0, width: 0)

        thumbnailCustomImageView.addSubview(playButton)
        playButton.anchor(top: thumbnailCustomImageView.topAnchor, left: nil, bottom: nil, right: thumbnailCustomImageView.rightAnchor, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, height: thumbnailCustomImageView.frame.height / 3, width: thumbnailCustomImageView.frame.width / 3)

    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

class VideoCollectionFooter: UICollectionViewCell {

    let label: UILabel = {
        let label = UILabel()
        label.font = UIFont.boldSystemFont(ofSize: 18)
        label.textColor = .white
        label.textAlignment = .center
        return label
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)

        setupVideoCollectionFooterView()
    }

    fileprivate func setupVideoCollectionFooterView() {
        addSubview(label)
        label.anchor(top: topAnchor, left: leftAnchor, bottom: bottomAnchor, right: rightAnchor, paddingTop: 10, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, height: 0, width: 0)
        label.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
        //label.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

メディア紹介

以下のメディア/サービスに『モニトレ』が紹介されました。

StartupTimes(スタートアップを取り上げるメディア):
https://startuptimes.jp/2020/08/19/211803/

Find Web(最新のウェブ/アプリを紹介するサイト):
https://findweb.jp/health/2481

NewLaun-ch(新サービスを紹介するサイト):
https://newlaun-ch.com/archives/products/bf77e713-418b-4337-a854-8fc8da38ab9b

最後に

現在はモニトレを使って継続的に筋トレとストレッチも出来ていて、筋力もかなりついてきたと実感しています。

『モニトレ』で目指したいのは"Endless Wellness=健康の永久化"が出来る世界です。

自らの首を痛めてしまって2週間身動きが取れないのは肉体的にも精神的にもとても辛い経験でした。

まさに『体は資本』を身に感じた経験でもあり、この記事をご覧のエンジニアの方には体に支障が出る前に予防して欲しいと願っています。

運動の継続が難しい方、モチベーションが上がらない方、 是非モニトレを利用してみてください。

無料で登録&利用出来ます。(GメールまたはAppleIDで登録可)

ダウンロードリンク:https://apple.co/3izK84n
公式ウェブサイト: https://monitore-f4d84.web.app/
YouTubeチャンネル:https://www.youtube.com/channel/UCdhcHH9OjyOnireh5vTpkRQ/

『モニトレ』のサービス内容に関しての質問、サービスフィードバック、今回紹介したコードの質問があればコメント欄へどうぞ!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Swift]アプリ起動中に、コードによりホーム画面に戻る方法

はじめに

とあるiOSアプリを触っていると、設定画面に言語変更のオプションがあり興味本位で変更してみるとアプリの再起動を促されて自動でホーム画面に戻るというUXを見つけました。そこでどういう実装をしているのか気になり調べてみました。
完成図↓

アプリを意図的に落とすUXはどうなのか

この点に関しては以下の記事に詳しく書かれていました。
https://news.mynavi.jp/article/20190528-iphone_why/

メモリ不足に起因するリソース不足などについてもシステム側でメモリの開放が行われることから、アプリを落とさなければならないという状況は極めて限定的ではあると考えられます。しかし表示言語の変更などのやむをえない場合についてはこのようなUXを実装しても問題なさそうです。

単純にアプリを落とす方法

ただ単にアプリを落とすだけなら、exit(0)などで落とすことができます。ただし、これだけだとアプリが突然クラッシュしたかのような挙動となりあまりふさわしいとは思えません。

また、exit(0)以外にも、fatalError()やassertなどで落とすことができますが、今回の言語の変更を目的とした、使用用途としては不適だと言えます。これらの使い分けについては以下の記事が大変参考になりましたのでご覧ください。

https://scior.hatenablog.com/entry/2019/04/04/202352

exit(0)//今回使用する
fatalError()//不適
assert(false, "")//不適

そこで

そこでアプリを終了させる前にホーム画面に戻るという処理を行います。こうすることでクラッシュしたかのような挙動は避けることができました。
コードは以下の通り単純です。

UIControl().sendAction(#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil)

この処理の後に先程のexit(0)を呼べば良いのですが以下のように直後に呼んでしまうと一瞬画面がブラックアウトしたように見えてしまい、これもまた好ましい挙動ではありません。

UIControl().sendAction(#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil)
exit(0)

よって、タイマーを用いて、ほんの少しの間遅延させて実行することとします。
自分は以下のように実装しました。

完成したコード

UIControl().sendAction(#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil)
Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { _ in
            exit(0)
        }

最後に

これからちょくちょく記事を書いて行くのでよかったら見て行ってもらえたら嬉しいです。
何かご指摘や質問(わからないかもですが)などありましたら気軽にコメントでもDMでもください。

追記

AppStore公開時の審査について

StackOverflowなどでこの方法では審査に通らないとする意見も見られるそうです。
しかし自分で調べたところ大手アプリにおいてもこの方法を使用しているものが見受けられたので、一概に落とされるとも限りません。
もし、この方法を用いたアプリを審査申請された方がおられましたらどうなったかコメント頂けると助かります。自分も次のアプリ公開時にこの方法を使用して試してみようと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む