👨‍💻

【iOS】これまでに使用した技術

2024/07/14に公開

ポートフォリオです。以下の順番で記載します。

  • リリースしたアプリの概要
  • 発信活動
  • 使用した技術の詳細説明

リリースしたアプリの概要

これまでリリースしたiOSアプリは

  • UIKit 34個
  • SwiftUI 5個(内の2つがWatchOS)
  • Unity 1個

の合計40個のアプリをリリースしました。

成果

合計約12万ダウンロードです。(2024/06/22時点)

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

AppStore

コミュニケーション支援ツール(筆談・文字パッド)アプリのおすすめランキングで1位になりました。(2024年7月12日時点)

https://megalodon.jp/2024-0712-1049-24/https://app-liv.jp:443/health/assist/1526/

発信活動

発信活動はZenn・Qiitaで行っています。現在はZennを中心に発信しています。
2024年は合計40本の記事を投稿しています。(2024/07/18時点)

Zenn

https://zenn.dev/muranaka

直近であれば、HealthKit・SwiftData関連の発信をしました。

Qiita

https://qiita.com/muranakar

直近であれば、業務で用いたReactNativeの発信をしました。

学習したアーキテクチャ

これまでに学習したアーキテクチャのサンプルコードを以下に記します。

MVP

https://github.com/muranakar/GitHubSearchMVP

MVVM

https://github.com/muranakar/PokemonMVVM

VIPER

https://github.com/muranakar/VIPERSample

使用した技術の詳細説明

全てのアプリの使用した技術を振ります。

FIM:生活の自立度を評価する

https://apps.apple.com/jp/app/id1606480076

一人ひとりの動作レベルを評価するツールです。

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

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

https://apps.apple.com/jp/app/id1612725154

タイマー機能を用いた検査を行うツールです。

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

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

https://apps.apple.com/jp/app/id1616574755

認知症の検査で用いるツールです。

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

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

https://apps.apple.com/jp/app/id1624994936

医療・介護に関連した事業所を検索するツールです。事業所別に合計8個のマップアプリを作成しました。

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

  • MapKitCore Locationを用いたマップ表示
  • PickerViewKeyboardDelegateを用いたPickerViewの制御
  • UIActivityIndicatorViewを用いて、インジケーターの表示実装
  • UISegmentを用いて、条件ごとの検索結果の表示
  • WebKitをもちいて、事業所の詳細情報を表示する実装

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

https://apps.apple.com/jp/app/id1630835450

  • csvファイルを構造体へデコード
  • Core Graphics,Core Animation,DrawingUIBezierPath)などを用いて描画処理

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

https://apps.apple.com/jp/app/id1633155568

上部に表示される一つの文字を、中央部分の文字のリストから探す脳トレアプリです。

3×3の表示

7×7の表示

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

難聴の高齢者の方とのコミュニケーションアプリです。
筆談機能・音声認識機能を使用できます。

  • Speechを用いた音声認識
  • ローカライズ対応
  • iPhone、iPadのAutoLayoutでのUI調整
  • StoreKitを用いた、レビュー処理
  • ATTrackingManagerを用いたトラッキング実装
  • Admobを用いた広告実装
  • Firebase Crashlyticsを用いたクラッシュログレポートツールの実装

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

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/id1641708472

ランダムに数字・ひらがなが表示され、順番通りにタップする脳トレアプリです。

数字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を用いて、デバイスごとに表示する文字の数を変更する実装
デバイスを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/id1643364510

真ん中に表示された画像と同じ画像をタップする脳トレゲームです。

  • 画像をランダムに回転させる実装
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
    }
}

脳トレ123:動く数字をタップ!ランダム表示!高齢者向け

https://apps.apple.com/jp/app/id1661849645

ランダムに動いている数字を順番通りにタップする脳トレアプリです。

Unity、C#を用いて作成したアプリです。Unityを用いた理由は、物理エンジンを用いて実装する必要があり、Unityの方が実装コストが低いためです。また、Unityにチャレンジしたい気持ちもあり、開発に取り掛かりました。Unityのチュートリアルでは、ブロック崩し・脱出ゲームも実装しました。Unityで面白さを感じたポイントは、Game空間の中の一つのオブジェクトに対して、C#のスクリプト設定してオブジェクトの制御を行う部分です。

目標設定アプリ

https://apps.apple.com/jp/app/id1664607879

入力した目標をPDF出力し、紙で印刷するアプリです。

  • 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

筋トレ・ストレッチ時に用いるアプリです。
カウントの間隔をカスタマイズが可能であり、トレーニングプランのテンプレートを用意しています。

  • 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からデコード+staticメソッドで管理)

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を用いた実装
  • バナー広告がSwiftUIに対応していないため、SwiftUI上でUIKitの実装(UIViewRepresentableを用いて)
  • ダークモード対応、文字サイズ変更
  • .onTapGesture,.onReceive,.onPreferenceChange,.onAppear などのmodifierを用いた実装

ポモドーロタイマー(SwiftUI)

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化して、実装

例 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")
            )
    }
}

読み上げ カウントダウン(SwiftUI)

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

音声でカウントしたいときに用いるアプリです。

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

リズム運動 (SwiftUI・WatchOS)

音声でカウントしたいときに用いるWatchOS対応アプリです。

https://apps.apple.com/jp/app/id6466854178

ポモドーロタイマー (SwiftUI・WatchOS)

ポモドーロタイマー専用のWatchOS対応アプリです。

https://apps.apple.com/jp/app/id6466736728

文字認識アプリ(UIKit)

保存している写真・撮影した写真を使用して、画像の文字認識で文字出力するアプリです。

https://apps.apple.com/jp/app/id6473706737

  • Vision・VisionKitを用いた文字認識機能

画像生成AI(SwiftUI)

画像生成AIのAPIを用いたアプリです。

https://apps.apple.com/jp/app/id6479509633

最後に

沢山の技術に触れることができ、一つ一つ苦労したことも良い思い出です。
ポートフォリオを記載する必要があり、この記事を書きました。

良かったと思ったら、いいねやTwitterのフォローよろしくお願いいたします。

https://sites.google.com/view/muranakar

個人でアプリを作成しているので、良かったら覗いてみてください。

Discussion