#はじめに
初めまして, iOSエンジニアのakaakozこと小澤です。
今回は先月(2020年8月)リリースした周りのユーザーと監視しながら一緒に頑張るフィットネスiOSアプリ『モニトレ』を紹介したいと思います。
今年6月の中旬から開発をスタートして、9月15日にApp Storeにリリースしました。
この記事ではどんなサービスなのか、開発に至った背景、サービスの仕様と使用したコードについて書いていきます。
#モニトレとは?
『モニトレ』は週に設定した筋トレやエクササイズのノルマを達成しながら、周りのユーザーと一緒に頑張るフィットネスアプリです。
(※他ユーザーには自撮りのエクササイズ動画は閲覧出来ません。)
無料で登録&利用出来ます。(GメールまたはAppleIDで登録可)
例えば週に3回のノルマを設定した場合、週に3回自撮りしたエクササイズ動画をアップロードしないとアプリの"ナマケモノ"の欄に記載されてしまう、**"辱め"**という新しい形のモチベーションでユーザーの運動を継続させるサービスです。
以下の方にオススメのサービスです。
・運動の継続が難しい方、モチベーションが上がらない方
・値段が高いダイエットプログラムに加入せずに痩せたい方
・コロナ渦でジムに行けず、お家でトレーニングをしたい方
ダウンロードリンク:https://apple.co/3izK84n
公式ウェブサイト: https://monitore-f4d84.web.app/
#サービスを作った背景
私は前サービス『WalCal』(訪日外国人向け体験型サービス)を含めサービス開発をスタートしてから2年間、ほぼ休みなしでプログラミングをしていました。(週7日、起きている時間はほぼプログラミング)
そんな多忙な日々を送っていたある日、ストレートネックを発症し首を傷めてしまい、1週間ベットから起き上がれない状態になってしまいました。
あまりの痛みで自力で起き上がれなくなってしまったため、28歳にして親から介護を受ける事になってしまいました。(親には感謝です。ありがとう)
それから1週間後、自力で起き上がれる様になりましたが、まだ残っていた首の痛みとめまいからデスクに座って作業が出来ない状況がさらに1週間続きました。
以下の写真がその時に撮ったレントゲンです。(2020年6月3日)
運動の大切さを分かっていながらも忙しいことを理由に怠け癖がついていました。
あと"1時間後にやる"が"1日後にやる"になり気付いたらそれが1週間続き、運動&ストレッチを全くしない期間がザラにありました。
1日10分すら自らの健康の時間を取れずにストレートネックを発症し、2週間何も作業が出来ず大切な時間を失ってしまいました。
この私が経験した様な、運動が継続的に出来ない怠け癖を解決出来ないかと考え開発に至りました。
#仕様
###1.周りのユーザーを評価
モニトレは週に設定したノルマを達成しないと、アプリの"ナマケモノ"の欄に記載されてしまいます。(達成出来れば"ヤルキモノ"になります。)
1人ではどうしても怠けが出てしまいますので、周りのユーザーと励まし合い、時には喝を入れながら切磋琢磨出来る機能です。
###2.ノルマの達成方法は自撮りのエクササイズ動画をアップロード
まずはモニトレのエクササイズ動画(スクワット、腕立て、腹筋、ストレッチなどのメニュー)を選んで一緒にエクササイズします。それと同時に自撮りのレコーディングも行っているので、終わったらそのままアップロード出来る仕様となっています。(※他ユーザーには自撮りのエクササイズ動画は閲覧出来ません。)
実際に私がモニトレで筋トレをしている様子です(YouTube)
モニトレのエクササイズ動画ですがYouTubeにアップロードしたものをYouTube Data Apiで引っ張っています。
モニトレはスケジュール機能がついており、日々のエクササイズを管理できます。
メモ帳も使わずに過去のエクササイズ動画や自らのフォームもチェックできるため便利な機能かと思います。
後ほどこの機能の実装方法を紹介します。
#スケジュール機能のコードを紹介
開発言語はSwiftとデータベースはFirebaseを使用しています。(Android版も開発予定)
モニトレのスケジュール機能はサードパーティーライブラリのFSCalendarを使用して実装しています。
FSCalendar: https://github.com/WenchaoD/FSCalendar
カレンダー系アプリなどを作る時にものすごい便利なのでオススメです。
###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/
『モニトレ』のサービス内容に関しての質問、サービスフィードバック、今回紹介したコードの質問があればコメント欄へどうぞ!