🙆

【iOS】未経験エンジニアが使用してきた技術集

2023/05/15に公開

これまでにアプリをリリースしてきたので、使用した技術と学んだことを振り返る。
ポートフォリオのようなものです。

初回のアプリリリース日が、2022年1月23日で、そこから
UIKit32個 SwiftUI3個 Unity1個 のアプリをリリースしました。
現時点(2023/05/14時点)で、合計約7.5万ダウンロードです。

開発した順番で振り返っていきます。

FIM:生活の自立度を評価する!病院で必須評価!

https://apps.apple.com/jp/app/fim-生活の自立度を評価する-病院で必須評価-簡単シンプル/id1606480076
一人ひとりの動作レベルを、評価を行うために用いるツール

  • UITableViewによるリスト表示
  • UITextFieldによる入力処理
  • NavigationControllerを用いた画面遷移
  • AutoLayoutを用いてUI実装
  • XibによるTableViewのCellの表示
  • RepositoryパターンでのRealmのCRUD
  • QuickLook を用いたPDF出力
  • JSON→構造体への変換(Decodable)
  • Line、Twitterへの共有機能

詳細記事
https://qiita.com/muranakar/items/c3b9c068f62e07f654c9

個別タイム測定:一人ひとりの時間を計測

https://apps.apple.com/jp/app/個別タイム測定-一人ひとりの時間を計測-記録する-運動を測る/id1612725154
タイマー機能を用いた、動作・検査をおこなう評価ツール

  • QuartzCoreを用いたタイマー実装。
    → CADisplayLinkを用いてタイマー実装。(画面のリフレッシュレートに同期しているため。)
  • 今回のアプリとは別で、Timerを用いた実装も、サンプルアプリで行っている。

HDS-R:認知症を簡単に検査する:痴呆症を評価する

https://apps.apple.com/jp/app/hds-r-認知症を簡単に検査する-痴呆症を評価する-病院/id1616574755
認知症のスクリーニング検査で用いるツール

  • UIImageViewを用いた画像の表示
  • UITapGestureRecognizerを用いたタップ処理
  • UIVisualEffectView(frame: view.frame)を用いた背景のぼかし
  • UIViewControllerTransitioningDelegate、UIViewControllerAnimatedTransitioningによる画面遷移アニメーションのカスタマイズ

医療・介護にまつわる事業所を探すためのマップ

https://apps.apple.com/jp/app/発達障害-学習障害を支援する場所を探す地図アプリ-多動-遊び/id1624994936
医療・介護にまつわる事業所のマップ 計8個のマップアプリを作成

厚生労働省の医療・介護にまつわる事業所のデータベースを用いて実装。

  • CoreLocationを用いたマップ表示
     MKMapView,CLLocationManager,MKPointAnnotation(),MKCoordinateRegion()【緯度・経度用いて】,MKMapViewDelegate,CLLocationManagerDelegateを用いて
  • PickerViewKeyboardDelegateを用いてPickeViewの表示
  • UIActivityIndicatorViewを用いて、インジケーターの表示実装
  • UISegmentを用いて、条件ごとの検索結果の表示
  • WebKitをもちいて、事業所の詳細を閲覧できるように実装

もじつなぐ:認知症 発達障害の方の脳トレゲーム

https://apps.apple.com/jp/app/もじつなぐ-認知症-発達障害の方の脳トレゲーム-集中力向上/id1630835450

  • csvファイルを構造体へデコード
  • CoreGraphics,UIBezierPath,CALayer,Drawing などを用いて描画処理

みつけもじ:認知症 発達障害の脳トレゲーム

https://apps.apple.com/jp/app/みつけもじ-認知症-発達障害の脳トレゲーム-集中力up遊び/id1633155568
みつけもじ
上に表示されている文字を、探してタップするアプリ。

UICollectionViewの実装

  • UICollectionViewDataSourceを用いて、値の設定
  • UICollectionViewDelegateを用いて、タップされた際の処理の設定
  • UICollectionViewDelegateFlowLayoutを用いてレイアウトの設定

声日記・議事録・診察レコーダー

https://apps.apple.com/jp/app/議事録-自動文字起こし-録音-記録ができる簡単シンプル会議/id1635969637
一部仕様は変更しているが、大まかな機能は同じ。

-  録音ボタンのアニメーションの実装

  • AVFoundation(AVAudioRecorderを用いて録音)
  • Speechを用いた音声認識
  • NotificationCenterを用いて、バックグラウンド遷移したかを通知
  • FileManagerを用いた録音データ(.m4a)のCRUD
  • FSCalendar(ライブラリー)を用いて、カレンダーの実装
  • Calendarを用いたカレンダーの処理
  • AVAudioPlayerを用いた、音声ファイルの再生、再生開始時点の指定、スキップの実装
  • AVAudioPlayerDelegateを用いた、音声データ再生終了時の処理
  • UITextViewDelegateを用いた、テキスト入力中のデータ保存処理

コミュボード:耳が遠い方とのコミュニケーションツール

https://apps.apple.com/jp/app/コミュボード-耳が遠い方とのコミュニケーションツール-簡単/id1638036122
コミュボード
難聴の高齢者の方とのコミュニケーションを円滑にするためのアプリ

  • ローカライズ対応
  • iPhone、iPadのAutoLayoutでのUI調整
  • SKStoreReviewControllerを用いた、レビュー処理
  • ATTrackingManagerを用いたトラッキング実装
  • Admobを用いた広告実装


最高順位は、iPadの メディカル カテゴリー で 3位になりました。

印刷カレンダー:簡単シンプルに年月プリントできる

https://apps.apple.com/jp/app/印刷カレンダー-簡単シンプルに年月プリントできる-かんたん/id1639296709
 印刷カレンダー 
PDFでカレンダーを月ごとに印刷することができる。

  • カレンダーを作成する際に、HTML・CSSのコードを swiftで実装
protocol Builder {
    func build() -> String
}
struct Cell: Builder {
    func build() -> String {
        var content = "<div style=\"font-size: 30px;\">\(day)</div>"
        if let schedule1 = schedule1 {
            content += "<div style=\"font-size: 20px;\">\(schedule1)</div>"
        }
        if let schedule2 = schedule2 {
            content += "<div style=\"font-size: 20px;\">\(schedule2)</div>"
        }
        if let schedule3 = schedule3 {
            content += "<div style=\"font-size: 20px;\">\(schedule3)</div>"
        }

        return "<td>\(content)</td>"
    }
    init(day: Int, weekDay: WeekDay) {
        self.day = day
        self.weekDay = weekDay
        self.schedule1 = nil
        self.schedule2 = nil
        self.schedule3 = nil
    }
    init(day: Int,schedule1: String?,schedule2: String?,schedule3: String?, weekDay: WeekDay) {
        self.day = day
        self.weekDay = weekDay
        self.schedule1 = schedule1
        self.schedule2 = schedule2
        self.schedule3 = schedule3
    }
    init(day: Int,schedule1: String?, weekDay: WeekDay) {
        self.day = day
        self.weekDay = weekDay
        self.schedule1 = schedule1
        self.schedule2 = nil
        self.schedule3 = nil
    }

    var schedule1: String?
    let schedule2: String?
    let schedule3: String?
    let day: Int
    let weekDay: WeekDay
}

struct Row: Builder {
    func build() -> String {
        "<tr height=\"160\">\(cells.map{ $0.build() }.joined())</tr>"
    }
    let cells: [Cell]
}

struct Table: Builder {
    func build() -> String {
        "<table>\(rows.map{ $0.build() }.joined())</table>"
    }
    let rows: [Row]
}
  • PDFデータの作成
  • HTML,CSSを用いた、PDFでカレンダー作成
  • QuickLookを用いたPDFでカレンダー表示

家族爆弾ゲーム-写真を使って,みんなでドキドキ

https://apps.apple.com/jp/app/家族爆弾ゲーム-写真を使って-みんなでドキドキ-ワクワク/id1640584555
家族爆弾ゲーム
黒ひげ危機一発のようなハズレが一つ紛れていて、それを押すと動画が流れる。


  • UIImagePickerControllerを用いた写真の選択
  • RSKImageCropper(ライブラリー)を用いた、写真データの切り取り
  • UIGraphicsBeginImageContext,UIGraphicsGetCurrentContextを用いた描画処理
  • 描画処理から、 UIGraphicsGetImageFromCurrentImageContextを用いて画像を作成
  • 画像データの切り取った部分を透明にするために、pngデータに変更

脳トレ123:ランダム表示&難易度調整が可能!

https://apps.apple.com/jp/app/脳トレ123-ランダム表示-難易度調整が可能-子ども-高齢者/id1641708472
脳トレ123:ランダム表示&難易度調整が可能!子ども,高齢者
数字Version

ひらがなVersion

  • コードでUIButtonを生成したり、削除実施。
  • ランダムにUIButtonが表示されるため、重ならないようにするための実装。
ボタンの重なりを確認するためのコード
struct ButtonPosition {
    var minX: CGFloat
    var maxX: CGFloat
    var minY: CGFloat
    var maxY: CGFloat

    func isOverlap(labelPosition: ButtonPosition) -> Bool {
        let rangeX = minX...maxX
        let rangeY = minY...maxY

        let isOverlapMinX = rangeX.contains(labelPosition.minX)
        let isOverlapMaxX = rangeX.contains(labelPosition.maxX)
        let isOverlapMinY = rangeY.contains(labelPosition.minY)
        let isOverlapMaxY = rangeY.contains(labelPosition.maxY)

        if isOverlapMinX && isOverlapMinY { return true }
        if isOverlapMinX && isOverlapMaxY { return true }
        if isOverlapMaxX && isOverlapMinY { return true }
        if isOverlapMaxX && isOverlapMaxY { return true }
        return false
    }
}
  • UIDeviceを用いて、デバイスごとに表示する文字の量を変更している。iPhone,iPad別。
デバイスをBoolで判定するためのコード
struct DeviceType {
    static func isIPhone() -> Bool {
        return UIDevice.current.userInterfaceIdiom == .phone
    }
    static func isIPad() -> Bool {
        return UIDevice.current.userInterfaceIdiom == .pad
    }
}

脳トレ:ピクトグラムで注意力UP!ランダム表示&難易度調整

https://apps.apple.com/jp/app/脳トレ-ピクトグラムで注意力up-ランダム表示-難易度調整/id1643364510
脳トレ:ピクトグラムで注意力UP!ランダム表示&難易度調整
真ん中に指定された画像が表示され、それと同じ画像をタップする。
画像が回転しているため、メンタルローテーションのトレーニングになります。

  • 画像をランダムに回転させる実装
let randomRotationAngle = CGFloat(Float(Array(1...360).randomElement()!))
newImageView.transform = CGAffineTransform(rotationAngle: randomRotationAngle)

四則演算の記号を入れるゲーム

https://apps.apple.com/jp/app/脳トレ計算-シンプルで簡単な操作-暗算-算数-数学ドリル/id1644516996
脳トレ計算:シンプルで簡単な操作,暗算,算数,数学ドリル

  • 計算の実装
enum GameModeNumCount {
    case two
    case three
    case four
    case five
}

enum CalculationMethod: CaseIterable {
    case addition
    case subtraction
    case multiplication
    case division

    func textSymbol() -> String {
        switch self {
        case .addition:
            return "+"
        case .subtraction:
            return "-"
        case .multiplication:
            return "*"
        case .division:
            return "/"
        }
    }
    func textName() -> String {
        switch self {
        case .addition:
            return "addition"
        case .subtraction:
            return "subtraction"
        case .multiplication:
            return "multiplication"
        case .division:
            return "division"
        }
    }

}

struct FillBlankCalculation {
    var num1: Int
    var num2: Int
    var num3: Int?
    var num4: Int?
    var num5: Int?
    var result: Double?
    var gameModeNumCount: GameModeNumCount
    var correctAnswerCalculationMethods: [CalculationMethod]
    var answerCalculationMethods: [CalculationMethod]

    init (gameModeNumCount: GameModeNumCount) {
        self.gameModeNumCount = gameModeNumCount
        let randomNumArray = Array(1...9)
        num1 = randomNumArray.randomElement()!
        num2 = randomNumArray.randomElement()!
        switch gameModeNumCount {
        case .two:
            correctAnswerCalculationMethods =
            [CalculationMethod.allCases.randomElement()!]
        case .three:
            num3 = randomNumArray.randomElement()!
            correctAnswerCalculationMethods = [CalculationMethod.allCases.randomElement()!,CalculationMethod.allCases.randomElement()!]
        case .four:
            num3 = randomNumArray.randomElement()!
            num4 = randomNumArray.randomElement()!
            correctAnswerCalculationMethods = [CalculationMethod.allCases.randomElement()!,CalculationMethod.allCases.randomElement()!,CalculationMethod.allCases.randomElement()!]
        case .five:
            num3 = randomNumArray.randomElement()!
            num4 = randomNumArray.randomElement()!
            num5 = randomNumArray.randomElement()!
            correctAnswerCalculationMethods =
            [CalculationMethod.allCases.randomElement()!,
             CalculationMethod.allCases.randomElement()!,
             CalculationMethod.allCases.randomElement()!,
             CalculationMethod.allCases.randomElement()!]
        }
        answerCalculationMethods = []
        result = calculateResult(correctAnswerCalculationMethods)
    }

    mutating func processWhenTheCorrectAnswer() {
        resetAllNumAndResultAndCalculation()
    }
    mutating func processWhenTheInCorrectAnswer() {
        answerCalculationMethods = []
    }

    mutating func resetAllNumAndResultAndCalculation() {
        answerCalculationMethods = []
        correctAnswerCalculationMethods = []
        initializeAllNum()
        initializeCalculationMethods()
        result = calculateResult(correctAnswerCalculationMethods)
    }

    mutating func initializeAllNum() {
        let randomNumArray = Array(1...9)
        num1 = randomNumArray.randomElement()!
        num2 = randomNumArray.randomElement()!
        switch gameModeNumCount {
        case .two:
            break
        case .three:
            num3 = randomNumArray.randomElement()!
        case .four:
            num3 = randomNumArray.randomElement()!
            num4 = randomNumArray.randomElement()!
        case .five:
            num3 = randomNumArray.randomElement()!
            num4 = randomNumArray.randomElement()!
            num5 = randomNumArray.randomElement()!
        }
    }
    func calculateResult(_ calculationMethods: [CalculationMethod]) -> Double {
        var resultText = ""
        switch gameModeNumCount {
        case .two:
            resultText = "\(Double(num1))" + "\(calculationMethods[0].textSymbol())" + "\(Double(num2))"
        case .three:
            resultText = "\(Double(num1))" + "\(calculationMethods[0].textSymbol())" + "\(Double(num2))" + "\(calculationMethods[1].textSymbol())" + "\(Double(num3!))"
        case .four:
            resultText = "\(Double(num1))" + "\(calculationMethods[0].textSymbol())" + "\(Double(num2))" + "\(calculationMethods[1].textSymbol())" + "\(Double(num3!))" + "\(calculationMethods[2].textSymbol())" + "\(Double(num4!))"
        case .five:
            resultText = "\(Double(num1))" + "\(calculationMethods[0].textSymbol())" + "\(Double(num2))" + "\(calculationMethods[1].textSymbol())" + "\(Double(num3!))" + "\(calculationMethods[2].textSymbol())" + "\(Double(num4!))" + "\(calculationMethods[3].textSymbol())" + "\(Double(num5!))"
        }
        let expression = NSExpression(format: resultText)
        let answer = expression.expressionValue(with: nil, context: nil) as! Double
        return answer
    }

    mutating func initializeCalculationMethods() {
        switch gameModeNumCount {
        case .two:
            correctAnswerCalculationMethods =
            [CalculationMethod.allCases.randomElement()!]
        case .three:
            correctAnswerCalculationMethods =
            [CalculationMethod.allCases.randomElement()!,CalculationMethod.allCases.randomElement()!]
        case .four:
            correctAnswerCalculationMethods =
            [CalculationMethod.allCases.randomElement()!,
             CalculationMethod.allCases.randomElement()!,
             CalculationMethod.allCases.randomElement()!]
        case .five:
            correctAnswerCalculationMethods =
            [CalculationMethod.allCases.randomElement()!,
             CalculationMethod.allCases.randomElement()!,
             CalculationMethod.allCases.randomElement()!,
             CalculationMethod.allCases.randomElement()!]
        }
    }

    /// 回答するときに用いるメソッド
    mutating func answer(calculationMethod: CalculationMethod) {
        answerCalculationMethods.append(calculationMethod)
    }

    func isCorrectTheAnswer() -> Bool {
        let answerResult = calculateResult(answerCalculationMethods)
        if result == answerResult { return true }
            return false
    }
}
  • GameKitを用いた Game Centerの実装
struct GameCenter {
    var player: GKLocalPlayer
    func authenticateLocalPlayer() {
        let player = GKLocalPlayer.local
        player.authenticateHandler = { _, error in
            guard error == nil else {
                print(error?.localizedDescription ?? "")
                return
            }
        }
    }
    
    func showLocationTopLeft() {
        GKAccessPoint.shared.isActive = true
        GKAccessPoint.shared.location = .topLeading
    }

    func showLocationBottomLeft() {
        GKAccessPoint.shared.isActive = true
        GKAccessPoint.shared.location = .bottomLeading
    }

    static func hiddenUI() {
        GKAccessPoint.shared.isActive = false
    }

    func sendLeaderboardWithID(score: Int,leaderboardID: String) {
        if GKLocalPlayer.local.isAuthenticated {
            GKLeaderboard.submitScore(
                score,
                context: 0,
                player: GKLocalPlayer.local,
                leaderboardIDs: [leaderboardID]) { error in
                print(error ?? "")
            }
        } else {
            print("GameCenterにログインしていません")
        }
    }
}
  • AVFoundation (AVAudioPlayerなど) を用いて、正解時の音声出力
extension GameViewController: AVAudioPlayerDelegate {
    func playSound(name: String) {
        guard let path = Bundle.main.path(forResource: name, ofType: "mp3") else {
            print("音源ファイルが見つかりません")
            return
        }
        do {
            // AVAudioPlayerのインスタンス化
            audioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))

            // AVAudioPlayerのデリゲートをセット
            audioPlayer.delegate = self

            audioPlayer.prepareToPlay()
            if audioPlayer.isPlaying {
                        audioPlayer.stop()
                        audioPlayer.currentTime = 0
            }
            // 音声の再生
            audioPlayer.play()
        } catch {
        }
    }
}

記憶力に特化した脳トレ

https://apps.apple.com/jp/app/記憶力に特化した脳トレ-シンプルで簡単にトレーニング/id6443709224
記憶力に特化した脳トレ-シンプルで簡単にトレーニング

  • 画像 と 色を用いたゲームロジック、CustomViewを作成(MagicNumberが多く、反省)
enum ShapesAll {
    static let allUIColor: [UIColor] = [.blue,.darkGray,.magenta,.brown,.cyan,.green,.lightGray,.orange,.purple,.red,.systemIndigo]
    static let allColorAndColorTextDictionary: [UIColor: String] =
    [.blue: "blue",.darkGray: "darkGray",.magenta: "magenta",.brown:"brown",.cyan:"cyan",.green:"green",.lightGray:"lightGray",.orange:"orange",.purple:"purple",.red:"red",.systemIndigo:"systemIndigo"]

    static let allImageName: [String] = ["circle","circle.fill","square","square.fill","app","app.fill","rectangle","rectangle.fill","rectangle.portrait","rectangle.portrait.fill","capsule","capsule.fill","capsule.portrait","capsule.portrait.fill","oval","oval.fill","oval.portrait","oval.portrait.fill","triangle","triangle.fill","diamond","diamond.fill","octagon","octagon.fill","hexagon","hexagon.fill","pentagon","pentagon.fill","seal","seal.fill","rhombus","rhombus.fill","shield","shield.fill","rectangle.roundedtop","rectangle.roundedtop.fill","rectangle.roundedbottom","rectangle.roundedbottom.fill"]

    static func randomUIColor() -> UIColor {
        return allUIColor.randomElement()!
    }

    static func randomImageName() -> String {
        return allImageName.randomElement()!
    }
}

struct Shape {
    let color: UIColor
    let colorText: String
    let imageName: String
    init() {
        self.color = ShapesAll.randomUIColor()
        self.colorText = ShapesAll.allColorAndColorTextDictionary[color]!
        self.imageName = ShapesAll.randomImageName()
    }
}

class CustomImageView: UIImageView {
    let shape: Shape = Shape()
    let width = 60
    let height = 60

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

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

    func setup() {
        self.frame.size = .init(width: width, height: height) // 位置とサイズの指定
        self.tintColor = shape.color
        self.image = UIImage(systemName: shape.imageName)
        self.isUserInteractionEnabled = true
    }

    func isSameShape(customImageView: CustomImageView) -> Bool {
        if self.shape.color == customImageView.shape.color &&
            self.shape.imageName == customImageView.shape.imageName {
            return true
        }
        return false
    }
}

struct ImageViewPosition {
    var minX: CGFloat
    var maxX: CGFloat
    var minY: CGFloat
    var maxY: CGFloat

    func isOverlap(imageViewPosition: ImageViewPosition) -> Bool {
        let rangeX = minX...maxX
        let rangeY = minY...maxY

        let isOverlapMinX = rangeX.contains(imageViewPosition.minX)
        let isOverlapMaxX = rangeX.contains(imageViewPosition.maxX)
        let isOverlapMinY = rangeY.contains(imageViewPosition.minY)
        let isOverlapMaxY = rangeY.contains(imageViewPosition.maxY)

        if isOverlapMinX && isOverlapMinY { return true }
        if isOverlapMinX && isOverlapMaxY { return true }
        if isOverlapMaxX && isOverlapMinY { return true }
        if isOverlapMaxX && isOverlapMaxY { return true }
        return false
    }
}

struct ShapeGame {
    let defaultNumber: [Int]
    var problemSampleImageViewNumber: Int
    let imageViewWidthAndHeight: Int
    var problemRangeX: ClosedRange<CGFloat>
    var problemRangeY: ClosedRange<CGFloat>
    var answerRangeX: ClosedRange<CGFloat>
    var answerRangeY: ClosedRange<CGFloat>

    var answerRandomImageViewPosition: [ImageViewPosition] = []
    var answerRandomCustomImageView: [CustomImageView] = []
    var problemRandomImageViewPosition: [ImageViewPosition] = []
    var problemRandomCustomImageView: [CustomImageView] = []


    mutating func reset() {
        answerRandomImageViewPosition = []
        answerRandomCustomImageView = []
        problemRandomImageViewPosition = []
        problemRandomCustomImageView = []

        configureAnswerRandomImageViewPosition()
        configureAnswerRandomCustomImageView()
        configureProblemRandomImageViewPosition()
        configureProblemRandomCustomImageView()
    }
    mutating func resetAnswerRandomImageViewPosition() {
        answerRandomImageViewPosition = []
        configureAnswerRandomImageViewPosition()
        for i in 1...answerRandomImageViewPosition.count {
            let index: Int = i - 1
            answerRandomCustomImageView[index].frame = CGRect(
                x: Int(answerRandomImageViewPosition[index].minX),
                y: Int(answerRandomImageViewPosition[index].minY),
                width: imageViewWidthAndHeight,
                height: imageViewWidthAndHeight
            )
        }
    }


    mutating func configureAnswerRandomImageViewPosition() {
        for i in defaultNumber {
            while answerRandomImageViewPosition.count < i {
                let randomValueX = CGFloat.random(in: answerRangeX)
                let randomValueY = CGFloat.random(in: answerRangeY)
                let newImageViewPosition = ImageViewPosition(
                    minX: randomValueX,
                    maxX: randomValueX + CGFloat(imageViewWidthAndHeight),
                    minY: randomValueY,
                    maxY: randomValueY + CGFloat(imageViewWidthAndHeight)
                )
                let boolArray = answerRandomImageViewPosition.compactMap { imageViewPosition in
                    return imageViewPosition.isOverlap(imageViewPosition: newImageViewPosition)
                }
                if boolArray.contains(true) {
                    continue
                }
                answerRandomImageViewPosition.append(newImageViewPosition)
            }
        }
    }

    mutating func configureAnswerRandomCustomImageView() {
        for i in Array(1...answerRandomImageViewPosition.count) {
            let index: Int = i - 1
            let customImageView = CustomImageView(
                frame: CGRect(
                    x: Int(answerRandomImageViewPosition[index].minX),
                    y: Int(answerRandomImageViewPosition[index].minY),
                    width: imageViewWidthAndHeight,
                    height: imageViewWidthAndHeight
                )
            )
            answerRandomCustomImageView.append(customImageView)
        }
    }

    mutating func configureProblemRandomImageViewPosition() {
        let problemSampleImageViewArrayIndex = Array(0...problemSampleImageViewNumber - 1)
        for i in problemSampleImageViewArrayIndex {
            while problemRandomImageViewPosition.count - 1 < i {
                let randomValueX = CGFloat.random(in: problemRangeX)
                let randomValueY = CGFloat.random(in: problemRangeY)
                let newImageViewPosition = ImageViewPosition(
                    minX: randomValueX,
                    maxX: randomValueX + CGFloat(imageViewWidthAndHeight),
                    minY: randomValueY,
                    maxY: randomValueY + CGFloat(imageViewWidthAndHeight)
                )
                let boolArray = problemRandomImageViewPosition.compactMap { imageViewPosition in
                    return imageViewPosition.isOverlap(imageViewPosition: newImageViewPosition)
                }
                if boolArray.contains(true) {
                    continue
                }
                problemRandomImageViewPosition.append(newImageViewPosition)
            }
        }
    }

    mutating func configureProblemRandomCustomImageView() {
        problemRandomCustomImageView = Array( answerRandomCustomImageView.prefix(problemSampleImageViewNumber))
        var index = 0
        let newProblemRandomCustomImageView = problemRandomCustomImageView.map { customImageview in
            customImageview.frame = CGRect(
                x: Int(problemRandomImageViewPosition[index].minX),
                y: Int(problemRandomImageViewPosition[index].minY),
                width: imageViewWidthAndHeight,
                height: imageViewWidthAndHeight
            )
            index += 1
            return customImageview
        }
        problemRandomCustomImageView = newProblemRandomCustomImageView
    }

}

曜日・干支・山手線を用いた脳トレクイズ

https://apps.apple.com/jp/app/認知症対策-曜日クイズで-脳トレ-シンプルかんたん操作/id6443908223
https://apps.apple.com/jp/app/干支で脳トレ-認知症対策-シンプル簡単操作-老人から小児まで/id6443928188
https://apps.apple.com/jp/app/山手線クイズ-脳トレアプリ-シンプル簡単操作-認知症に対策/id6443969703
曜日・干支・山手線を用いた脳トレクイズ

  • ゲームロジックの実装  曜日version
//曜日
enum DayOfTheWeek: Int, Equatable {
    case sunday = 1
    case monday = 2
    case tuesday = 3
    case wednesday = 4
    case thursday = 5
    case friday = 6
    case saturday = 7

    func textJapanese() -> String {
        switch self {
        case .sunday:
            return "日"
        case .monday:
            return "月"
        case .tuesday:
            return "火"
        case .wednesday:
            return "水"
        case .thursday:
            return "木"
        case .friday:
            return  "金"
        case .saturday:
            return  "土"
        }
    }

    //曜日の判定
    static func set(for date: Date) -> Self {
        let calendar = Calendar(identifier: .gregorian)
        let weekdayNumber = calendar.component(.weekday, from: date)
        let weekDay = DayOfTheWeek(rawValue: weekdayNumber)!
        return weekDay
    }
}

//Quiz用のイニシャライザ
extension DayOfTheWeek {
    init?(quizValue: Int) {
    // 7で割りきれた余りによって判定
        var quizValue = quizValue % 7 
        if quizValue == 0 {
            quizValue = 7
        }
//マイナスだったら1週間分に進めて曜日を決定
        if quizValue < 0 { 
            quizValue = quizValue + 7
        }

        self.init(rawValue: quizValue)
    }
}

//Quizのレベル
enum QuizLevel {
    case hard
    case normal
    case easy
}

enum DateTextFromNum {
    case dateAfter(dateNum: Int)
    case dateBefore(dateNum: Int)

    init(dateNum: Int) {
        if dateNum > 0 {
            self = .dateAfter(dateNum: dateNum)
        } else if dateNum < 0{
            self = .dateBefore(dateNum: dateNum)
        } else {
            fatalError("DateTextFromNumに、引数に誤りがあります。")
        }
    }

    func text() -> String {
        switch self {
        case .dateAfter(dateNum: 2):
            return "明後日"
        case .dateAfter(dateNum: 1):
            return "明日"
        case .dateBefore(dateNum: -1):
            return "昨日"
        case .dateBefore(dateNum: -2):
            return "一昨日"
        case .dateAfter(dateNum: let dateNum):
            return "\(dateNum)日後"
        case .dateBefore(dateNum: let dateNum):
            return "\(abs(dateNum))日前"
        }
    }
}

struct Quiz {
    private let level: QuizLevel
    private let today = Date()

    private let firstDateNumber: Int
    private let secondDateNumber: Int
    private let thirdDateNumber: Int

    var text: String {
        let firstText = DateTextFromNum(dateNum: firstDateNumber).text()
        let secondText = DateTextFromNum(dateNum: secondDateNumber).text()
        let thirdText = DateTextFromNum(dateNum: thirdDateNumber).text()

        let totalText: String
        switch level {
        case .hard: totalText = "今日の\(firstText)の\n\(secondText)の\n\(thirdText)は、\n何曜日?"
        case .normal: totalText = "今日の\(firstText)の\n\(secondText)は、\n何曜日?"
        case .easy: totalText = "今日の\(firstText)は、\n何曜日?"
        }

        return totalText
    }

    init(level: QuizLevel) {
        self.level = level
        var randomNumber: Int {
            Array(-7...7)
                .filter{ $0 != 0 }
                .randomElement()!
        }

        firstDateNumber = randomNumber
        secondDateNumber = randomNumber
        thirdDateNumber = randomNumber
    }

    //Test用
    init(first: Int, second: Int, third: Int, level : QuizLevel) {
        firstDateNumber = first
        secondDateNumber = second
        thirdDateNumber = third
        self.level = level
    }

    func answer() -> DayOfTheWeek {
        let weekDay = DayOfTheWeek.set(for: today)

        // 今日の曜日に各クイズの日数を合算
        let weekDayNumber: Int
        switch level {
        case .hard:
            weekDayNumber = weekDay.rawValue + firstDateNumber + secondDateNumber + thirdDateNumber
        case .normal:
            weekDayNumber = weekDay.rawValue + firstDateNumber + secondDateNumber
        case .easy:
            weekDayNumber = weekDay.rawValue + firstDateNumber
        }
        return DayOfTheWeek(quizValue: weekDayNumber)!
    }
}

struct WeekDayGame {
    private var quiz: Quiz
    let quizLevel: QuizLevel
    var correctCount: Int = 0
    var missCount: Int = 0


    init(level: QuizLevel) {
        quiz = Quiz(level: level)
        quizLevel = level
    }

    init(testQuiz: Quiz,level: QuizLevel) {
        quiz = testQuiz
        quizLevel = level
    }

    func displayQuizText() -> String {
        quiz.text
    }

    mutating func reset() {
        self.quiz = Quiz(level: quizLevel)
    }

    mutating func answer(input: DayOfTheWeek) -> Bool {
        let answer = quiz.answer()
        if answer == input {
            self.correctCount += 1
        } else {
            self.missCount += 1
        }
        return answer == input ? true : false
    }
    func answer() -> DayOfTheWeek{
        return quiz.answer()
    }
}

数字を用いた記憶力を鍛える脳トレ

https://apps.apple.com/jp/app/認知症対策-記憶力の向上に特化した脳トレ-簡単シンプル操作/id6444015375
認知症対策:記憶力の向上に特化した脳トレ,簡単シンプル操作

  • UIBottonから、CustomButtonを作成して実装
class CustomButton: UIButton {
    let width = 60
    let height = 60
    var showedNumber: Int?

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

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

    func setup() {
        self.frame.size = .init(width: width, height: height) // 位置とサイズの指定
        self.isUserInteractionEnabled = true
    }
}
  • Buttonのポジションと、重なっているかどうかを判定
struct ButtonPosition {
    var minX: CGFloat
    var maxX: CGFloat
    var minY: CGFloat
    var maxY: CGFloat

    func isOverlap(buttonPosition: ButtonPosition) -> Bool {
        let rangeX = minX...maxX
        let rangeY = minY...maxY

        let isOverlapMinX = rangeX.contains(buttonPosition.minX)
        let isOverlapMaxX = rangeX.contains(buttonPosition.maxX)
        let isOverlapMinY = rangeY.contains(buttonPosition.minY)
        let isOverlapMaxY = rangeY.contains(buttonPosition.maxY)

        if isOverlapMinX && isOverlapMinY { return true }
        if isOverlapMinX && isOverlapMaxY { return true }
        if isOverlapMaxX && isOverlapMinY { return true }
        if isOverlapMaxX && isOverlapMaxY { return true }
        return false
    }
}

動いている数字を順番通りにタップする脳トレ

https://apps.apple.com/jp/app/脳トレ123-動く数字をタップ-ランダム表示-高齢者向け/id1661849645
脳トレ123:動く数字をタップ!ランダム表示!高齢者向け


Unity製です。
物理エンジンを用いて実装する必要があり、Unityの方が物理エンジンの実装がやりやすく、今回用いた。
チュートリアルとして、ブロック崩し、脱出ゲームも実装しました。
Unityは、Game空間の中の一つのオブジェクト(立方体・球)に対して、C#のスクリプトを設定できるところに、面白さを感じた。ただUnityはGUI操作が多く、UIKitの方がコード管理する部分が多く、個人的に実装しやすかったため、この1作のみとした。


https://apps.apple.com/jp/app/目標を紙に印刷できるアプリ-簡単操作でプリントできる/id1664607879
目標を紙に印刷できるアプリ,簡単操作でプリントできる

  • PDFで 文章を出力の実装
final class PDF {
    private let goalFontSize: Int
    private let toDoFontSize: Int
    private let goalTexts: [String]
    private let toDoTexts: [String]
    private let space: Int
    init(
        goalFontSize: Int,
        toDoFontSize: Int,
        goalText: String,
        toDoText: String,
        space: Int
    ) {
        self.goalFontSize = goalFontSize
        self.toDoFontSize = toDoFontSize
        let goalArray = goalText.split(whereSeparator: \.isNewline).map { String($0) }
        self.goalTexts = goalArray
        let toDoArray = toDoText.split(whereSeparator: \.isNewline).map { String($0) }
        self.toDoTexts = toDoArray
        self.space = space
    }
    
    private func getPDF() -> NSData {
        let renderer = UIPrintPageRenderer()
        let verticalPaperSize = CGSize(width: 595.2, height: 841.8)
        let paperFrame = CGRect(origin: .zero, size: verticalPaperSize)
        renderer.setValue(paperFrame, forKey: "paperRect")
        renderer.setValue(paperFrame, forKey: "printableRect")
        let formatter = UIMarkupTextPrintFormatter(markupText: makeHTMLString())
        renderer.addPrintFormatter(formatter, startingAtPageAt: 0)
        let pdfData = NSMutableData()
        UIGraphicsBeginPDFContextToData(pdfData, .zero, [:])
        for pageI in 0..<renderer.numberOfPages {
            UIGraphicsBeginPDFPage()
            renderer.drawPage(at: pageI, in: UIGraphicsGetPDFContextBounds())
        }
        UIGraphicsEndPDFContext()
        return pdfData
    }
    
    func saveToTempDirectory() -> URL? {
        let tempDirectory = NSURL.fileURL(withPath: NSTemporaryDirectory(), isDirectory: true)
        let fileName = "GoalToDo" + ".pdf"
        let filePath = tempDirectory.appendingPathComponent(fileName)
        do {
            try getPDF().write(to: filePath)
            return filePath
        } catch {
            print(error.localizedDescription)
            return nil
        }
    }
    // swiftlint:disable:next function_body_length
    private func makeHTMLString() -> String {
        return """
        <!DOCTYPE html>
        <html>
        <head>
            <title></title>
            <style>
                table,
                th,
                tr,
                td {
                    table-layout: fixed;
                    text-align: left;
                    vertical-align: top;
                    width: 100%;
                    border: 1px solid black;
                    border-collapse: collapse;
                    margin: 0%;
                }
            </style>
        <body>
            <div id="YCalTbl" style="font-size: 50px;padding: \(space)px" class="calleft">
                <div><span style="font-size: 50px;"></span></div>
                <table border="1" cellpadding="10" cellspacing="0">
                    <tbody>
                \(changeGoalTextHTML())
                \(changeToDoTextHTML())
                    </tbody>
                </table>
            </div>
        </body>
        </html>

        """
    }

    func changeGoalTextHTML() -> String {
        var totaltext = ""
        goalTexts.forEach { goalText in
            totaltext +=
            """
            <div><span style="font-size: \(goalFontSize)px;">\(goalText)</span></div>
            """
        }
        totaltext += "<br>"
        return totaltext
    }
    func changeToDoTextHTML() -> String {
        var totaltext = ""
        toDoTexts.forEach { toDoText in
            totaltext +=
            """
            <div><span style="font-size: \(toDoFontSize)px;">\(toDoText)</span></div>
            """
        }
        return totaltext
    }
}

https://apps.apple.com/jp/app/カウンター/id1669979381
カウンター+

  • Countの履歴をRealmでCRUD (Repositoryパターンで実装)
final class CountHistoryRepasitory {
    private let realm = try! Realm()
    // 並び替えあり
    func loadCountHistoryAndRemoveMoreThan300(ascending: Bool) -> [CountHistory] {
        let realmCountHistorys = realm
            .objects(RealmCountHistory.self)
            .sorted(byKeyPath: "createdAt",ascending: ascending)
        let realmCountHistorysArray = Array(realmCountHistorys)
        let countHistorys = realmCountHistorysArray.map {CountHistory(managedObject: $0) }
        // 先頭の300個以外削除
        Array(countHistorys.dropFirst(300)).forEach { countHistory in
            removeCountHistory(countHistory: countHistory)
        }
        let first100CountHistorys = Array(countHistorys.prefix(300))
        return first100CountHistorys
    }
    func apppendCountHistory(countHistory: CountHistory) {
        try! realm.write {
            let realmCountHistory = countHistory.managedObject()
            realm.add(realmCountHistory)
        }
    }

    func removeCountHistory(countHistory: CountHistory) {
        guard let countHistory = realm.object(
            ofType: RealmCountHistory.self,
            forPrimaryKey: countHistory.uuidString
        ) else { return }
        // swiftlint:disable:next force_cast
        try! realm.write {
            realm.delete(countHistory)
        }
    }
}

struct CountHistory {
    var uuidString = UUID().uuidString
    var countValue: Int
    var createdAt: Date = Date()
    var uuid: UUID? {
        UUID(uuidString: uuidString)
    }
}

class RealmCountHistory: Object {
    @Persisted(primaryKey: true)
    var uuidString = ""
    @Persisted
    var countValue: Int
    @Persisted
    var createdAt: Date
    var uuid: UUID? {
        UUID(uuidString: uuidString)
    }
    convenience init(countValue: Int,createdAt: Date) {
        self.init()
        self.countValue = countValue
        self.createdAt = createdAt
    }
}

private extension CountHistory {
    init(managedObject: RealmCountHistory) {
        self.uuidString = managedObject.uuidString
        self.countValue = managedObject.countValue
        self.createdAt = managedObject.createdAt
    }

    func managedObject() -> RealmCountHistory {
        let realmCountHistory = RealmCountHistory()
        realmCountHistory.uuidString = self.uuidString
        realmCountHistory.countValue = self.countValue
        realmCountHistory.createdAt = self.createdAt
        return realmCountHistory
    }
}
  • カウントが設定した達成値に到達したら、音がなる必要があるので、達成値をRealmでCRUD
final class AchievementRepasitory {
    private let realm = try! Realm()
    // 並び替えあり
    func loadAchievement(ascending: Bool) -> [Achievement] {
        let realmAchievements = realm
            .objects(RealmAchievement.self)
            .sorted(byKeyPath: "value", ascending: ascending)
        let realmAchievementsArray = Array(realmAchievements)
        let achievements = realmAchievementsArray.map {Achievement(managedObject: $0) }
        return achievements
    }

    func apppendAchievement(achievement: Achievement) {
        try! realm.write {
            let realmAchievement = achievement.managedObject()
            realm.add(realmAchievement)
        }
    }

    func removeAchievement(achievement: Achievement) {
        guard let achievement = realm.object(
            ofType: RealmAchievement.self,
            forPrimaryKey: achievement.uuidString
        ) else { return }
        // swiftlint:disable:next force_cast
        try! realm.write {
            realm.delete(achievement)
        }
    }
}

struct Achievement {
    var uuidString = UUID().uuidString
    var value: Int
    var uuid: UUID? {
        UUID(uuidString: uuidString)
    }
}

class RealmAchievement: Object {
    @Persisted(primaryKey: true)
    var uuidString = ""
    @Persisted
    var value: Int

    var uuid: UUID? {
        UUID(uuidString: uuidString)
    }
    convenience init(value: Int) {
        self.init()
        self.value = value
    }
}

private extension Achievement {
    init(managedObject: RealmAchievement) {
        self.uuidString = managedObject.uuidString
        self.value = managedObject.value
    }

    func managedObject() -> RealmAchievement {
        let realmAchievement = RealmAchievement()
        realmAchievement.uuidString = self.uuidString
        realmAchievement.value = self.value
        return realmAchievement
    }
}

https://apps.apple.com/jp/app/タイマー-筋トレ/id6446249318
タイマー 筋トレ

OKかCancelが表示されるアラート、テキストフィールドありのアラートの 実装

final class Alert {

    static func okAndCancelAlert(vc: UIViewController, title: String, message: String, handler: ((UIAlertAction) -> Void)? = nil) {
        let cancelAlertVC = UIAlertController(title: title, message: message, preferredStyle: .alert)
        cancelAlertVC.addAction(UIAlertAction(title: "OK", style: .default, handler: handler))
        cancelAlertVC.addAction(UIAlertAction(title: "キャンセル", style: .cancel, handler: nil))
        vc.present(cancelAlertVC, animated: true, completion: nil)
    }

    static func textFieldAlert(vc: UIViewController, title: String, message: String,text: String, placeholder: String, securyText: Bool,isNumberPad: Bool, handler: ((String?) -> Void)? = nil) {
        let textFieldAlertVC = UIAlertController(title: title, message: message, preferredStyle: .alert)
        textFieldAlertVC.addTextField { (textField) in
            textField.placeholder = placeholder
            textField.text = text
            if isNumberPad {
                textField.keyboardType = .numberPad
            }
        }

        textFieldAlertVC.addAction(UIAlertAction(title: "OK", style: .default, handler: { (_) in
            handler?(textFieldAlertVC.textFields?.first?.text)
        }))
        textFieldAlertVC.addAction(UIAlertAction(title: "キャンセル", style: .cancel, handler: nil))
        vc.present(textFieldAlertVC, animated: true, completion: nil)
    }
}

参考URL
https://qiita.com/1997/items/42b07c0cc1318d964292

  • MBCircularProgressBar(ライブラリー)を用いてProgressBarの実装
  • Realmの初期値追加の処理
  • Timerのロジック 【Timer用のデリゲートを作成して】(多いため一部のみ)
    →休憩・トレーニングのタイミングで、読み上げ・音声出力を実装する必要があったため、デリゲートを用いて処理を分けた。
import Foundation
enum TimerMode {
    case notRunning
    case running
    case continueRunning
    case pausing
}

class CountDownTimer {
    var delegate: CountDownTimerDelegate!

    enum TraningType {
        case training
        case rest
        case restBetweenSets
        case preparation
    }

    var isPause: Bool {
        switch timerMode {
        case .notRunning,.running, .continueRunning:
            return false
        case .pausing:
            return true
        }
    }
    var isRunning: Bool {
            switch timerMode {
            case .running,.continueRunning:
                return true
            case .notRunning, .pausing:
                return false
            }
        }

    var isContinueRunning: Bool {
        switch timerMode {
        case .notRunning,.running, .pausing:
            return false
        case .continueRunning:
            return true
        }
    }

    var trainigModeText: String {
        switch trainigMode {
        case .training:
            return NSLocalizedString("training", comment: "")
        case .rest:
            return NSLocalizedString("rest", comment: "")
        case .restBetweenSets:
            return NSLocalizedString("restBetweenSets", comment: "")
        case .preparation:
            return NSLocalizedString("preparation", comment: "")
        }
    }
    ---
    省略
    ---

    func start() {
        print("スタート")
        timerMode = .running
        delegate?.functionAllWhenStartTimerMode()
        startPreparation()
    }

    func continueRunning() {
        print("再開")
        timerMode = .continueRunning
        delegate?.functionAllWhenStartTimerMode()
        isImmediatelyAfterContinueRunning = true
        switch trainigMode {
        case .training:
            startTraning()
        case .rest:
            startRest()
        case .restBetweenSets:
            startRestBetweenSets()
        case .preparation:
            startPreparation()
        }
    }

    func pause() {
        print("一時停止")
        timerMode = .pausing
        delegate?.functionAllWhenStartTimerMode()
        timer.invalidate()
    }

    func reset() {
        trainigMode = .preparation
        timerMode = .notRunning
        delegate?.functionAllWhenStartTimerMode()
        timer.invalidate()
        resetInitValue()
        delegate?.functionWhenReset()
    }
    ---
    省略
    ---
}

protocol CountDownTimerDelegate {
    func functionAllStart()

    func functionTraningStart()
    func functionTraningInterval()
    func functionTraningWhenTheValueReachesZero()

    ---
    省略
    ---
    
    func functionAllWhenStartTimerMode()
    func functionWhenReset()
    func functionWhenTimerEnds()
    func functionWhenAllInterval()
    func functionWhenAllThreeSecondsAgo()
}

以下全て SwiftUIで作成

テンプレートが用意されている読み上げアプリ

https://apps.apple.com/jp/app/読み上げ/id6446442219
読み上げ

CSVファイルの作成(CSVからデコード+スタティックメソッドで管理)

CSVデータ一覧


struct CSV {
    private static let allFileName: [String] = Array(1...22).map { String($0) }
    private static var countCalledConvertMethod = 0
    private static func callCSVDataText(fileName: String) -> String {
        var csvString: String = ""
            if let path = Bundle.main.path(forResource: fileName, ofType: "csv") {
                do {
                    csvString = try String(contentsOfFile: path, encoding: .utf8)
                } catch {
                    print("Error reading CSV file: \(error)")
                }
            } else {
                print("Error finding CSV file")
            }
        return csvString
    }

    private static func convertCSVDataToDictionary(_ csvData: String) -> [String: [String]] {
        var dictionary: [String: [String]] = [:]

        let rows = csvData.components(separatedBy: .newlines)

            if let key = rows.first {
                let values = Array(rows.dropFirst())
                countCalledConvertMethod += 1
                let renamedKey = String(countCalledConvertMethod) + "." + key
                dictionary[renamedKey] = values
            }

        return dictionary
    }

    static func convertAllCSVDateToDictionary() -> [String: [String]] {
        var dictionary: [String: [String]] = [:]
        allFileName.forEach { fileName in
            let csvText = callCSVDataText(fileName: fileName)
            let csvfileDictonary = convertCSVDataToDictionary(csvText)
            dictionary.merge(csvfileDictonary) { (_, new) in new }
        }
        return dictionary
    }
}
  • ScrollViewを用いたタグのタイトル表示
  • Listを用いたリスト表示
  • @AppStorage,@State, @Environment,@FocusState
  • UIViewRepresentableを用いて、SwiftUI上でUIKitを用いて、バナー広告表示
  • ダークモード対応、文字サイズ変更
  • onTapGesture,onReceive,.onPreferenceChange,onAppear などを用いた実装

ポモドーロタイマー

https://apps.apple.com/jp/app/ポモドーロタイマー/id6447015140
ポモドーロタイマー+

  • ロック画面ウィジェットの実装
  • ローカル通知の実装
  static private func makeNotification(afterTime: Int,timerMode: TimerMode) {
        let notificationIdentifier = "\(UUID())"
        let notificationDate = Date().addingTimeInterval(TimeInterval(afterTime))
        let dateComp = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: notificationDate)

        let trigger = UNCalendarNotificationTrigger(dateMatching: dateComp, repeats: false)

        let content = UNMutableNotificationContent()
        switch timerMode {

        case .work:
            content.title = NSLocalizedString("workStart", comment: "")
        case .rest:
            content.title = NSLocalizedString("breakStart", comment: "")
        }
        content.sound = UNNotificationSound.default

        let request = UNNotificationRequest(identifier: notificationIdentifier, content: content, trigger: trigger)

        UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
    }
    static func remove() {
        let center = UNUserNotificationCenter.current()
        center.removeAllPendingNotificationRequests()
    }
  • Timerのロジックを入れたTimerManagerの作成(ObservableObject,@Published を用いて)
enum TimerState {
    case stopped, running, paused
}
enum TimerMode: String, CaseIterable ,Identifiable {
    case work = "作業"
    case rest = "休憩"

    var id: String { self.rawValue }
}
class TimerManager: ObservableObject {
    @Published var timerMode: TimerMode = .work
    @Published var timerState: TimerState = .stopped
    @Published var progress: Double = 1.0
    @Published var setCount = 0

    @AppStorage("workTimeIndex") var workTimeIndex = 2 {
        willSet {
            remainingTime = workTimes[newValue]
        }
    }
    @AppStorage("breakTimeIndex") var breakTimeIndex = 0 {
        willSet {
            remainingTime = breakTimes[newValue]
        }
    }
    @Published var timer: Timer?
    @Published var remainingTime: TimeInterval = 25 * 60

    let workTimes: [TimeInterval] = [5 * 60, 10 * 60, 25 * 60, 45 * 60,60 * 60]
    let breakTimes: [TimeInterval] = [5 * 60, 10 * 60, 15 * 60]

    var startTime: Date?
    var pauseTime: Date?

    private var elapsedTimeFromStart: TimeInterval {
        if let startTime = startTime {
           let resultElapsedTime = Date().timeIntervalSince(startTime)
            return resultElapsedTime
        } else {
            return 0
        }
    }

    init() {
        remainingTime = workTimes[workTimeIndex]
    }

    func start(workTimeIndex: Int, breakTimeIndex: Int) {
        startTime = Date()
        timerState = .running
        timerMode = .work
        initWorkTime(workTimeIndex: workTimeIndex)
        initBreakTime(breakTimeIndex: breakTimeIndex)
        timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
            guard let self = self else { return }
            self.timerMode = self.timerModeRelativeToElapsedTime()
            self.setCount = self.timerSetsRelativeElapsedTime()
            self.remainingTime = self.floorTimeInterval(self.elapsedTimeFromEachTimerMode())
            self.progress = self.remainingTime / self.totalTimeByTimerMode
        }
    }

    func floorTimeInterval(_ timeInterval: TimeInterval) -> TimeInterval {
        return TimeInterval(Int(timeInterval))
    }

    func pause() {
        timerState = .paused
        pauseTime = Date()
        timer?.invalidate()
        timer = nil
    }

    func resume() {
        timerState = .running
        let timeThatTheTimerHasAlreadyElapsed = startTime?.timeIntervalSince(pauseTime!)
        startTime = Date().addingTimeInterval(timeThatTheTimerHasAlreadyElapsed!)
        timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
            guard let self = self else { return }
            self.timerMode = self.timerModeRelativeToElapsedTime()
            self.setCount = self.timerSetsRelativeElapsedTime()
            self.remainingTime = self.floorTimeInterval(self.elapsedTimeFromEachTimerMode())
            self.progress = self.remainingTime / self.totalTimeByTimerMode
        }
    }
    func reset() {
        timer?.invalidate()
        timer = nil
        setCount = 0
        timerState = .stopped
        timerMode = .work
        initWorkTime(workTimeIndex: workTimeIndex)
    }

    func initWorkTime(workTimeIndex: Int) {
        self.workTimeIndex = workTimeIndex
        remainingTime = totalTimeByTimerMode
        progress = 1.0
    }

    func initBreakTime(breakTimeIndex: Int) {
        self.breakTimeIndex = breakTimeIndex
        remainingTime = totalTimeByTimerMode
        progress = 1.0
    }

    var totalTimeByTimerMode: TimeInterval {
        switch timerMode {
        case .work:
            return workTimes[workTimeIndex]
        case .rest:
            return breakTimes[breakTimeIndex]
        }
    }

    func timerModeRelativeToElapsedTime() -> TimerMode {
        let workTime = workTimes[workTimeIndex]
        let breakTime = breakTimes[breakTimeIndex]
        let totalTime = workTime + breakTime
        let remainder = Int(elapsedTimeFromStart) % Int(totalTime)
        if remainder < Int(workTime) {
            return .work
        } else if Int(workTime) <= remainder {
            return .rest
        }
        fatalError()
    }
    func timerSetsRelativeElapsedTime() -> Int {
        let workTime = workTimes[workTimeIndex]
        let breakTime = breakTimes[breakTimeIndex]
        let totalTime = workTime + breakTime
        let set = Int(elapsedTimeFromStart) / Int(totalTime)
        return set
    }

    func elapsedTimeFromEachTimerMode() -> TimeInterval {
        let workTime = workTimes[workTimeIndex]
        let breakTime = breakTimes[breakTimeIndex]
        let totalTime = workTime + breakTime
        let remainder = Int(elapsedTimeFromStart) % Int(totalTime)
        if remainder < Int(workTime) {
            return workTime - TimeInterval(remainder)
        } else if Int(workTime) <= remainder {
            let result = remainder - Int(workTime)
            return breakTime - TimeInterval(result)
        }
        fatalError()
    }
}

-ViewをComponent化して、実装(View・@Bindingを用いて)

例 ProgressViewを用いて
struct CircularProgressView: View {
    @Binding var timeLeft: TimeInterval
    @Binding var progress: Double
    @Binding var setCount: Int

    let totalTime: TimeInterval
    var color: Color

    let timeFormatter: DateComponentsFormatter = {
        let formatter = DateComponentsFormatter()
        formatter.allowedUnits = [.minute, .second]
        formatter.zeroFormattingBehavior = .pad
        return formatter
    }()

    init(timeLeft: Binding<TimeInterval>,progress: Binding<Double>,setCount: Binding<Int>,color: Color) {

        self._timeLeft = timeLeft
        self.totalTime = timeLeft.wrappedValue
        self.color = color
        self._progress = progress
        self._setCount = setCount
    }

    var body: some View {
        ZStack {
            Circle()
                .stroke(lineWidth: 20)
                .opacity(0.3)
                .foregroundColor(.gray)
            Circle()
                .trim(from: 0.0, to: CGFloat(min(progress, 1.0)))
                .stroke(style: StrokeStyle(lineWidth: 20, lineCap: .round, lineJoin: .round))
                .foregroundColor(color)
                .rotationEffect(Angle(degrees: 270.0))
                .animation(.linear, value: progress)
            VStack{
                Text(timeFormatter.string(from: timeLeft) ?? "")
                    .font(Font(UIFont.monospacedDigitSystemFont(ofSize: 80, weight: .regular)))
                    .fontWeight(.bold)
                    .minimumScaleFactor(0.5)
                    .frame(maxWidth: .infinity)
                    .fixedSize(horizontal: true, vertical: false)
                Text("\(setCount)" + NSLocalizedString("set", comment: ""))
            }
        }
    }
}

struct CircularProgressView_Previews: PreviewProvider {
    static var previews: some View {
        CircularProgressView(
            timeLeft: .constant(25 * 60),
            progress: .constant(1.0),
            setCount: .constant(1),
            color: Color.init("ThemeColor")
            )
    }
}

運動時などで用いるカウントダウン・アップの読み上げアプリ

https://apps.apple.com/jp/app/読み上げ-カウントダウン/id6447489589
読み上げ カウントダウン

  • AVSpeechSynthesizerDelegateを用いたSwiftUIでのデリゲートの実装
  • ViewModelを用いた実装

以下は学習記録

ポケモンAPIを用いた通信処理の学習 (リリースはしていない)

https://qiita.com/muranakar/items/4cc56bbcea8ec7205bc6

https://qiita.com/muranakar/items/64ecc59279b1ebe6289c

https://qiita.com/muranakar/items/09e03ab7d17f3c837fe3

ポケモンAPIを用いたテスト周りの学習 (リリースはしていない)

https://qiita.com/muranakar/items/7be22d46eb85ec53bf8b

https://qiita.com/muranakar/items/223a5905d0c3c8fc99ae


以上です。

振り返ってみると、書ききれば切りがないほど、課題が多いです。ですが、沢山の技術に触れることができ、一つ一つ苦労したことも、良い思い出です。これからは、デザインパターン、設計、テスト周りの学習を進めていきたい。

以上になります。自分がどのようなアプリを作成しているのか、ポートフォリオ的な記事を記載しておきたいと思い、このような記事を書きました。

他にも良い方法があれば、コメントいただけると大変うれしいです。
良かったと思ったら、いいねやTwitterのフォローよろしくお願いいたします!

https://sites.google.com/view/muranakar
個人でアプリを作成しているので、良かったら覗いてみてください!

Discussion