본문 바로가기

iOS 프로그래밍

iOS 프로그래밍_9주차

Do it! 스위프트로 아이폰 앱 만들기 입문, 송호정, 이범근 저,이지스퍼블리싱, 2023년 01월 20일

----------------------------------------
02 Hello World 앱 만들며 Xcode에 완벽 적응하기
03 원하는 이미지 화면에 출력하기 - 이미지 뷰
04 데이트 피커 사용해 날짜 선택하기
05 피커 뷰 사용해 원하는 항목 선택하기

06 얼럿 사용해 경고 표시하기
07 웹 뷰로 간단한 웹 브라우저 만들기

08 맵 뷰로 지도 나타내기

09 페이지 이동하기 - 페이지 컨트롤
10 탭 바 컨트롤러 이용해 여러 개의 뷰 넣기
11 내비게이션 컨트롤러 이용해 화면 전환하기
12 테이블 뷰 컨트롤러 이용해 할 일 목록 만들기
13 음악 재생하고 녹음하기
14 비디오 재생 앱 만들기
15 카메라와 포토 라이브러리에서 미디어 가져오기
16 코어 그래픽스로 화면에 그림 그리기
17 탭과 터치 사용해 스케치 앱 만들기
18 스와이프 제스처 사용하기
19 핀치 제스처 사용해 사진을 확대/축소하기

 

09 페이지 이동하기 - 페이지 컨트롤

 

import UIKit

// 이미지 파일 이름들을 담은 배열
var images = [ "01.png", "02.png", "03.png", "04.png", "05.png", "06.png" ]

class ViewController: UIViewController {
    
    // 이미지 뷰와 페이지 컨트롤을 IBOutlet으로 연결
    @IBOutlet var imgView: UIImageView!
    @IBOutlet var pageControl: UIPageControl!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // 뷰가 로드될 때 실행되는 코드
        
        // 페이지 컨트롤에 표시할 페이지 수를 이미지 배열의 개수로 설정
        pageControl.numberOfPages = images.count
        // 현재 페이지를 3으로 설정 (처음에는 3번 페이지로 설정)
        pageControl.currentPage = 3
        
        // 페이지 컨트롤의 비활성 페이지 인디케이터 색상을 회색으로 설정
        pageControl.pageIndicatorTintColor = UIColor.gray
        // 현재 페이지 인디케이터 색상을 검은색으로 설정
        pageControl.currentPageIndicatorTintColor = UIColor.black
        
        // 첫 번째 이미지(0번 인덱스)를 이미지 뷰에 표시
        imgView.image = UIImage(named: images[0])
    }

    // 페이지 컨트롤의 페이지가 변경되었을 때 호출되는 액션 메소드
    @IBAction func pageChange(_ sender: UIPageControl) {
        // 페이지 컨트롤의 현재 페이지에 해당하는 이미지를 이미지 뷰에 표시
        imgView.image = UIImage(named: images[pageControl.currentPage])
    }
    
}

 

 

10 탭 바 컨트롤러 이용해 여러 개의 뷰 넣

import UIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }

    @IBAction func btnMoveImageView(_ sender: UIButton) {
        tabBarController?.selectedIndex = 1
    }
    
    @IBAction func btnMoveDatePickerView(_ sender: UIButton) {
        tabBarController?.selectedIndex = 2
    }
}

 

11 내비게이션 컨트롤러 이용해 화면 전환하기

import UIKit

class ViewController: UIViewController, EditDelegate {

    let imgOn = UIImage(named: "lamp_on.png")
    let imgOff = UIImage(named: "lamp_off.png")
    
    var isOn = true
    
    @IBOutlet var txMessage: UITextField!
    @IBOutlet var imgView: UIImageView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        imgView.image = imgOn
    }

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        let editViewController = segue.destination as! EditViewController
        if segue.identifier == "editButton" {
            editViewController.textWayValue = "segue : use button"
        } else if segue.identifier == "editBarButton" {
            editViewController.textWayValue = "segue : use Bar button"
        }
        editViewController.textMessage = txMessage.text!
        editViewController.isOn = isOn
        editViewController.delegate = self
    }
    
    func didMessageEditDone(_ controller: EditViewController, message: String) {
        txMessage.text = message
    }
    
    func didImageOnOffDone(_ controller: EditViewController, isOn: Bool) {
        if isOn {
            imgView.image = imgOn
            self.isOn = true
        } else {
            imgView.image = imgOff
            self.isOn = false
        }
    }
    
}

 

12 테이블 뷰 컨트롤러 이용해 할 일 목록 만들기

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    // UITableView에서 셀을 재사용하기 위해 dequeueReusableCell 메소드를 호출하여 셀을 가져옴
    let cell = tableView.dequeueReusableCell(withIdentifier: "myCell", for: indexPath)

    // 해당 행의 텍스트 레이블에 데이터를 설정
    cell.textLabel?.text = items[(indexPath as NSIndexPath).row]
    
    // 해당 행에 맞는 이미지를 셀의 이미지 뷰에 설정
    cell.imageView?.image = UIImage(named: itemsImageFile[(indexPath as NSIndexPath).row])

    // 셀을 반환
    return cell
}

 

iOS 앱을 만들 때 가장 많이 사용되는 `UIViewController` 종류는 다음과 같습니다. 

각기 다른 목적에 맞게 사용되며, 앱의 다양한 화면과 흐름을 관리하는 데 중요한 역할을 합니다.

1. UIViewController
   - 기본적인 뷰 컨트롤러로, 사용자 인터페이스의 모든 종류를 관리할 수 있습니다.
   - `UIViewController`는 기본 뷰 컨트롤러 클래스이며, 거의 모든 화면을 만들 때 사용됩니다.
   - 버튼, 레이블, 이미지 뷰 등을 추가하여 앱의 기본적인 화면을 구성합니다.

 2. UITableViewController
   - 리스트 형태의 데이터를 표시하는 데 사용됩니다. `UITableViewController`는 `UITableView`와 관련된 기능을 많이 자동으로 처리해 줍니다.
   - 예를 들어, 데이터를 세로로 나열한 리스트 형태로 보여주는 화면에서는 `UITableViewController`를 사용합니다.
   - `UITableViewController`는 `UITableViewDataSource`와 `UITableViewDelegate`를 자동으로 설정하여 `UITableView`를 쉽게 다룰 수 있게 해줍니다.

 3. UICollectionViewController
   - 그리드 형태로 데이터를 표시할 때 사용됩니다. `UICollectionViewController`는 `UICollectionView`를 쉽게 사용할 수 있도록 도와줍니다.
   - 예를 들어, 사진 갤러리, 아이템 그리드 등에서 자주 사용됩니다.
   - `UICollectionView`는 리스트뿐만 아니라 다양한 형태의 레이아웃을 지원하므로, 복잡한 레이아웃을 구현할 때 유용합니다.

4. UINavigationController
   - `UINavigationController`는 **내비게이션 스택**을 관리하는 컨트롤러로, 화면 간의 전환을 쉽게 처리할 수 있게 해줍니다.
   - `UINavigationController`는 스택 구조를 이용하여 여러 화면을 계층적으로 탐색할 수 있게 합니다. 각 화면은 `push`로 스택에 쌓이고, `pop`으로 돌아올 수 있습니다.
   - 예를 들어, 리스트에서 세부 항목으로 이동하는 경우에 사용됩니다. 이때 내비게이션 바와 함께 제목, 뒤로 가기 버튼 등을 자동으로 관리합니다.

5. UITabBarController
   - `UITabBarController`는 **탭 바**를 사용하여 앱의 여러 섹션을 쉽게 전환할 수 있도록 해줍니다.
   - 주로 앱의 하단에 탭 바를 두고 여러 화면(예: 홈, 검색, 설정 등)을 전환할 때 사용됩니다.
   - 탭을 클릭하여 다른 화면으로 빠르게 전환할 수 있게 하며, 여러 개의 화면을 관리하는 데 유용합니다.

 6. UIPageViewController
   - `UIPageViewController`는 페이지 기반의 화면 전환을 관리하는 데 사용됩니다. 페이지를 넘기듯 화면을 전환할 수 있게 해줍니다.
   - 주로 페이지 뷰 형식으로 화면을 전환할 때 사용됩니다. 예를 들어, 페이지가 넘겨지는 방식으로 이미지나 내용을 보여주는 화면에서 사용됩니다.

7. UIAlertController
   - `UIAlertController`는 알림(Alert) 또는 액션 시트(Action Sheet) 를 표시하는 데 사용됩니다.
   - 예를 들어, 사용자에게 중요한 메시지를 보여주거나, 선택을 요구하는 버튼을 제공하는 등의 경우에 사용됩니다.

 8. UISplitViewController
   - `UISplitViewController`는 두 개의 뷰를 나누어 보여주는 컨트롤러입니다. 주로 아이패드에서 많이 사용되며, 큰 화면에서 여러 뷰를 동시에 보여줄 때 유용합니다.
   - 일반적으로 하나의 뷰는 마스터 뷰로, 다른 하나는 디테일 뷰로 사용됩니다. 예를 들어, 왼쪽에는 목록이, 오른쪽에는 상세 내용이 표시되는 형식입니다.

 9. MKMapViewController (혹은 MKMapView)
   - `MKMapViewController`는 지도를 표시하고 상호작용할 수 있는 뷰를 제공합니다.
   - 주로 위치 기반 서비스와 관련된 앱에서 사용됩니다. 예를 들어, 위치 검색, 마커 추가, 현재 위치 표시 등과 관련된 화면을 구현할 때 사용됩니다.

10. UIActivityViewController
   - `UIActivityViewController`는 공유 기능을 제공하는 컨트롤러입니다.
   - 예를 들어, 사용자에게 사진, 텍스트, 웹 링크 등을 다른 앱과 공유할 수 있는 기능을 제공할 때 사용됩니다.

---

가장 많이 사용되는 `UIViewController`는?

1. `UIViewController`– 거의 모든 앱에서 기본 화면을 관리하는 컨트롤러로 사용됩니다.
2. `UITableViewController` – 리스트 기반 UI를 많이 사용하는 앱에서 사용됩니다. (e.g. 연락처, 뉴스 피드, 이메일 등)
3. `UINavigationController` – 여러 화면 간에 내비게이션을 처리할 때 사용됩니다.
4. `UITabBarController` – 앱의 여러 섹션을 탭으로 전환하는 앱에서 널리 사용됩니다.

이 외에도 `UICollectionViewController`, `UIPageViewController` 등도 많이 사용되지만, 기본적으로 앱에서 **목록 표시, 화면 전환, 탭**을 담당하는 컨트롤러들이 가장 흔하게 사용됩니다.


13 음악 재생하고 녹음하기

//
//  ViewController.swift
//  Audio
//
//  Created by BeomGeun Lee on 2021.
//

import UIKit // UIKit 프레임워크를 가져옴
import AVFoundation // 오디오 재생 및 녹음을 위한 AVFoundation 프레임워크를 가져옴

class ViewController: UIViewController, AVAudioPlayerDelegate, AVAudioRecorderDelegate { // UIViewController를 상속하고 AVAudioPlayerDelegate 및 AVAudioRecorderDelegate 프로토콜을 채택

    var audioPlayer: AVAudioPlayer! // 오디오 재생을 위한 AVAudioPlayer 인스턴스
    var audioFile: URL! // 오디오 파일의 URL

    let MAX_VOLUME: Float = 10.0 // 최대 볼륨 상수

    var progressTimer: Timer! // 재생 상태 업데이트를 위한 타이머

    // 타이머 업데이트 메서드를 위한 선택자
    let timePlayerSelector: Selector = #selector(ViewController.updatePlayTime)
    let timeRecordSelector: Selector = #selector(ViewController.updateRecordTime)

    // UI 요소 IBOutlet 연결
    @IBOutlet var pvProgressPlay: UIProgressView! // 재생 진행 상태를 표시하는 UIProgressView
    @IBOutlet var lblCurrentTime: UILabel! // 현재 재생 시간을 표시하는 UILabel
    @IBOutlet var lblEndTime: UILabel! // 오디오의 총 시간을 표시하는 UILabel
    @IBOutlet var btnPlay: UIButton! // 재생 버튼
    @IBOutlet var btnPause: UIButton! // 일시 정지 버튼
    @IBOutlet var btnStop: UIButton! // 정지 버튼
    @IBOutlet var slVolume: UISlider! // 볼륨 조절 슬라이더

    @IBOutlet var btnRecord: UIButton! // 녹음 버튼
    @IBOutlet var lblRecordTime: UILabel! // 녹음 시간을 표시하는 UILabel

    var audioRecorder: AVAudioRecorder! // 오디오 녹음을 위한 AVAudioRecorder 인스턴스
    var isRecordMode = false // 녹음 모드 여부를 나타내는 플래그

    override func viewDidLoad() { // 뷰가 로드된 후 호출되는 메서드
        super.viewDidLoad() // 부모 클래스의 viewDidLoad 호출
        selectAudioFile() // 오디오 파일 선택
        if !isRecordMode { // 녹음 모드가 아닐 경우
            initPlay() // 재생 초기화
            btnRecord.isEnabled = false // 녹음 버튼 비활성화
            lblRecordTime.isEnabled = false // 녹음 시간 레이블 비활성화
        } else { 
            initRecord() // 녹음 초기화
        }
    }

    func selectAudioFile() { // 오디오 파일 선택 메서드
        if !isRecordMode { // 녹음 모드가 아닐 경우
            audioFile = Bundle.main.url(forResource: "Sicilian_Breeze", withExtension: "mp3") // 번들에서 오디오 파일 URL 가져오기
        } else { // 녹음 모드일 경우
            let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] // 문서 디렉토리 경로 가져오기
            audioFile = documentDirectory.appendingPathComponent("recordFile.m4a") // 녹음 파일 URL 설정
        }
    }

    func initRecord() { // 녹음을 초기화하는 메서드
        // 녹음 설정 정의
        let recordSettings = [
            AVFormatIDKey: NSNumber(value: kAudioFormatAppleLossless as UInt32), // 포맷 설정
            AVEncoderAudioQualityKey: AVAudioQuality.max.rawValue, // 오디오 품질 설정
            AVEncoderBitRateKey: 320000, // 비트 레이트 설정
            AVNumberOfChannelsKey: 2, // 채널 수 설정
            AVSampleRateKey: 44100.0 // 샘플링 레이트 설정
        ] as [String: Any]
        
        do {
            audioRecorder = try AVAudioRecorder(url: audioFile, settings: recordSettings) // AVAudioRecorder 인스턴스 생성
        } catch let error as NSError {
            print("Error-initRecord: \(error)") // 오류 발생 시 출력
        }
        
        audioRecorder.delegate = self // 델리게이트 설정
        slVolume.value = 1.0 // 슬라이더의 초기 볼륨 설정
        audioPlayer.volume = slVolume.value // 오디오 플레이어 볼륨 설정
        lblEndTime.text = convertNSTimeInterval2String(0) // 끝 시간 레이블 초기화
        lblCurrentTime.text = convertNSTimeInterval2String(0) // 현재 시간 레이블 초기화
        setPlayButtons(false, pause: false, stop: false) // 플레이 버튼 상태 설정
        
        let session = AVAudioSession.sharedInstance() // 오디오 세션 인스턴스 가져오기
        do {
            try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default) // 세션 카테고리 설정
            try AVAudioSession.sharedInstance().setActive(true) // 세션 활성화
        } catch let error as NSError {
            print("Error-setCategory: \(error)") // 오류 발생 시 출력
        }
        do {
            try session.setActive(true) // 세션 활성화
        } catch let error as NSError {
            print("Error-setActive: \(error)") // 오류 발생 시 출력
        }
    }

    func initPlay() { // 재생 초기화 메서드
        do {
            audioPlayer = try AVAudioPlayer(contentsOf: audioFile) // AVAudioPlayer 인스턴스 생성
        } catch let error as NSError {
            print("Error-initPlay: \(error)") // 오류 발생 시 출력
        }
        
        slVolume.maximumValue = MAX_VOLUME // 슬라이더 최대 값 설정
        slVolume.value = 1.0 // 슬라이더 초기 값 설정
        pvProgressPlay.progress = 0 // 진행 바 초기화
        
        audioPlayer.delegate = self // 델리게이트 설정
        audioPlayer.prepareToPlay() // 오디오 준비
        audioPlayer.volume = slVolume.value // 오디오 플레이어 볼륨 설정
        
        lblEndTime.text = convertNSTimeInterval2String(audioPlayer.duration) // 끝 시간 레이블 설정
        lblCurrentTime.text = convertNSTimeInterval2String(0) // 현재 시간 레이블 초기화
        setPlayButtons(true, pause: false, stop: false) // 플레이 버튼 상태 설정
    }

    func setPlayButtons(_ play: Bool, pause: Bool, stop: Bool) { // 버튼 상태 설정 메서드
        btnPlay.isEnabled = play // 재생 버튼 상태 설정
        btnPause.isEnabled = pause // 일시 정지 버튼 상태 설정
        btnStop.isEnabled = stop // 정지 버튼 상태 설정
    }

    func convertNSTimeInterval2String(_ time: TimeInterval) -> String { // NSTimeInterval을 문자열로 변환하는 메서드
        let min = Int(time / 60) // 분 계산
        let sec = Int(time.truncatingRemainder(dividingBy: 60)) // 초 계산
        let strTime = String(format: "%02d:%02d", min, sec) // 형식 지정 문자열 생성
        return strTime // 변환된 문자열 반환
    }

    @IBAction func btnPlayAudio(_ sender: UIButton) { // 재생 버튼 액션
        audioPlayer.play() // 오디오 재생
        setPlayButtons(false, pause: true, stop: true) // 버튼 상태 설정
        progressTimer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: timePlayerSelector, userInfo: nil, repeats: true) // 타이머 시작
    }
    
    @objc func updatePlayTime() { // 재생 시간 업데이트 메서드
        lblCurrentTime.text = convertNSTimeInterval2String(audioPlayer.currentTime) // 현재 시간 레이블 업데이트
        pvProgressPlay.progress = Float(audioPlayer.currentTime / audioPlayer.duration) // 진행 바 업데이트
    }

    @IBAction func btnPauseAudio(_ sender: UIButton) { // 일시 정지 버튼 액션
        audioPlayer.pause() // 오디오 일시 정지
        setPlayButtons(true, pause: false, stop: true) // 버튼 상태 설정
    }

    @IBAction func btnStopAudio(_ sender: UIButton) { // 정지 버튼 액션
        audioPlayer.stop() // 오디오 정지
        audioPlayer.currentTime = 0 // 현재 시간 초기화
        lblCurrentTime.text = convertNSTimeInterval2String(0) // 현재 시간 레이블 초기화
        setPlayButtons(true, pause: false, stop: false) // 버튼 상태 설정
        progressTimer.invalidate() // 타이머 중지
    }

    @IBAction func slChangeVolume(_ sender: UISlider) { // 볼륨 슬라이더 변경 시 호출되는 메서드
        audioPlayer.volume = slVolume.value // 오디오 플레이어 볼륨 설정
    }

    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { // 오디오 재생 완료 시 호출


14 비디오 재생 앱 만들기

import UIKit
import AVKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // 뷰가 로드된 후 추가 설정을 위한 곳
        // 현재는 초기화나 설정 작업이 필요 없으므로 비워둡니다.
    }

    // 내부에 저장된 비디오 파일을 재생하는 버튼 액션
    @IBAction func btnPlayInternalMovie(_ sender: UIButton) {
        // 앱 번들 안에 저장된 mp4 파일의 경로를 가져옵니다.
        let filePath:String? = Bundle.main.path(forResource: "FastTyping", ofType: "mp4")
        
        // 경로가 유효하다면 NSURL 객체로 변환
        if let filePath = filePath {
            let url = NSURL(fileURLWithPath: filePath) // NSURL 객체로 URL 생성
            playVideo(url: url) // 비디오 재생 함수 호출
        } else {
            print("File not found") // 파일 경로가 없다면 에러 메시지 출력
        }
    }
    
    // 외부 URL에서 비디오를 재생하는 버튼 액션
    @IBAction func btnPlayerExternalMovie(_ sender: UIButton) {
        // 외부 URL에서 mp4 파일을 가져옵니다.
        let url = NSURL(string: "https://dl.dropboxusercontent.com/s/e38auz050w2mvud/Fireworks.mp4")!
        
        // 비디오 재생 함수 호출
        playVideo(url: url)
    }
    
    // 비디오 URL을 받아서 AVPlayerViewController를 사용하여 비디오를 재생하는 함수
    private func playVideo(url: NSURL) {
        let playerController = AVPlayerViewController() // 비디오 플레이어 컨트롤러 생성
        
        // AVPlayer 인스턴스를 생성하여 URL로 비디오를 로드
        let player = AVPlayer(url: url as URL)
        playerController.player = player // AVPlayer를 AVPlayerViewController에 설정
        
        // 비디오 플레이어 화면을 현재 화면에 띄운 후, 재생 시작
        self.present(playerController, animated: true) {
            player.play() // 비디오 재생 시작
        }
    }
}


15 카메라와 포토 라이브러리에서 미디어 가져오기


import UIKit
import MobileCoreServices

class ViewController: UIViewController, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
    @IBOutlet var imgView: UIImageView!
    
    let imagePicker: UIImagePickerController! = UIImagePickerController()
    var captureImage: UIImage!
    var videoURL: URL!
    var flagImageSave = false

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }

    @IBAction func btnCaptureImageFromCamera(_ sender: UIButton) {
        if (UIImagePickerController.isSourceTypeAvailable(.camera)) {
            flagImageSave = true
            
            imagePicker.delegate = self
            imagePicker.sourceType = .camera
            imagePicker.mediaTypes = ["public.image"]
            imagePicker.allowsEditing = false
            
            present(imagePicker, animated: true, completion: nil)
        }
        else {
            myAlert("Camera inaccessable", message: "Application cannot access the camera.")
        }
    }
    
    @IBAction func btnLoadImageFromLibrary(_ sender: UIButton) {
        if (UIImagePickerController.isSourceTypeAvailable(.photoLibrary)) {
            flagImageSave = false
            
            imagePicker.delegate = self
            imagePicker.sourceType = .photoLibrary
            imagePicker.mediaTypes = ["public.image"]
            imagePicker.allowsEditing = true
            
            present(imagePicker, animated: true, completion: nil)
        }
        else {
            myAlert("Photo album inaccessable", message: "Application cannot access the photo album.")
        }
    }
    
    @IBAction func btnRecordVideoFromCamera(_ sender: UIButton) {
        if (UIImagePickerController.isSourceTypeAvailable(.camera)) {
            flagImageSave = true
            
            imagePicker.delegate = self
            imagePicker.sourceType = .camera
            imagePicker.mediaTypes = ["public.movie"]
            imagePicker.allowsEditing = false
            
            present(imagePicker, animated: true, completion: nil)
        }
        else {
            myAlert("Camera inaccessable", message: "Application cannot access the camera.")
        }
    }
    
    @IBAction func btnLoadVideoFromLibrary(_ sender: UIButton) {
        if (UIImagePickerController.isSourceTypeAvailable(.photoLibrary)) {
            flagImageSave = false
            
            imagePicker.delegate = self
            imagePicker.sourceType = .photoLibrary
            imagePicker.mediaTypes = ["public.movie"]
            imagePicker.allowsEditing = false
            
            present(imagePicker, animated: true, completion: nil)
        }
        else {
            myAlert("Photo album inaccessable", message: "Application cannot access the photo album.")
        }
    }
    
    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        let mediaType = info[UIImagePickerController.InfoKey.mediaType] as! NSString

        if mediaType.isEqual(to: "public.image" as String) {
            captureImage = info[UIImagePickerController.InfoKey.originalImage] as? UIImage

            if flagImageSave {
                UIImageWriteToSavedPhotosAlbum(captureImage, self, nil, nil)
            }

            imgView.image = captureImage
        }
        else if mediaType.isEqual(to: "public.movie" as String) {
            if flagImageSave {
                videoURL = (info[UIImagePickerController.InfoKey.mediaURL] as! URL)

                UISaveVideoAtPathToSavedPhotosAlbum(videoURL.relativePath, self, nil, nil)
            }
        }

        self.dismiss(animated: true, completion: nil)
    }
    
    func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
        self.dismiss(animated: true, completion: nil)
    }

    func myAlert(_ title: String, message: String) {
        let alert = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert)
        let action = UIAlertAction(title: "Ok", style: UIAlertAction.Style.default, handler: nil)
        alert.addAction(action)
        self.present(alert, animated: true, completion: nil)
    }
}

 

16 코어 그래픽스로 화면에 그림 그리기

import UIKit

class ViewController: UIViewController {
    @IBOutlet var imgView: UIImageView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }

    @IBAction func btnDrawLine(_ sender: UIButton) {
        UIGraphicsBeginImageContext(imgView.frame.size)
        let context = UIGraphicsGetCurrentContext()!
        
        // Draw Line
        context.setLineWidth(2.0)
        context.setStrokeColor(UIColor.red.cgColor)
        
        context.move(to: CGPoint(x: 70, y: 50))
        context.addLine(to: CGPoint(x: 270, y: 250))
        
        context.strokePath()
        
        // Draw Triangle
        context.setLineWidth(4.0)
        context.setStrokeColor(UIColor.blue.cgColor)
        
        context.move(to: CGPoint(x: 170, y: 200))
        context.addLine(to: CGPoint(x: 270, y: 350))
        context.addLine(to: CGPoint(x:  70, y: 350))
        context.addLine(to: CGPoint(x: 170, y: 200))
        context.strokePath()
        
        imgView.image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
    }
    
    @IBAction func btnDrawRectangle(_ sender: UIButton) {
        UIGraphicsBeginImageContext(imgView.frame.size)
        let context = UIGraphicsGetCurrentContext()!
        
        // Draw Rectangle
        context.setLineWidth(2.0)
        context.setStrokeColor(UIColor.red.cgColor)
        
        context.addRect(CGRect(x: 70, y: 100, width: 200, height: 200))
        context.strokePath()
        
        imgView.image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
    }
    
    @IBAction func btnDrawCircle(_ sender: UIButton) {
        UIGraphicsBeginImageContext(imgView.frame.size)
        let context = UIGraphicsGetCurrentContext()!
        
        // Draw Ellipse
        context.setLineWidth(2.0)
        context.setStrokeColor(UIColor.red.cgColor)
        
        context.addEllipse(in: CGRect(x: 70, y: 50, width: 200, height: 100))
        context.strokePath()
        
        // Draw Circle
        context.setLineWidth(5.0)
        context.setStrokeColor(UIColor.green.cgColor)
        
        context.addEllipse(in: CGRect(x: 70, y: 200, width: 200, height: 200))
        context.strokePath()
        
        imgView.image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
    }
    
    @IBAction func btnDrawArc(_ sender: UIButton) {
        UIGraphicsBeginImageContext(imgView.frame.size)
        let context = UIGraphicsGetCurrentContext()!
        
        // Draw Arc
        context.setLineWidth(5.0)
        context.setStrokeColor(UIColor.red.cgColor)
        
        context.move(to: CGPoint(x: 100, y: 50))
        context.addArc(tangent1End: CGPoint(x: 250, y:50), tangent2End: CGPoint(x:250, y:200), radius: CGFloat(50))
        context.addLine(to: CGPoint(x: 250, y: 200))
        
        context.move(to: CGPoint(x: 100, y: 250))
        context.addArc(tangent1End: CGPoint(x: 270, y:250), tangent2End: CGPoint(x:100, y:400), radius: CGFloat(20))
        context.addLine(to: CGPoint(x: 100, y: 400))
        
        context.strokePath()
        
        imgView.image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
    }
    
    @IBAction func btnDrawFill(_ sender: UIButton) {
        UIGraphicsBeginImageContext(imgView.frame.size)
        let context = UIGraphicsGetCurrentContext()!
        
        // Draw Rectangle
        context.setLineWidth(1.0)
        context.setStrokeColor(UIColor.red.cgColor)
        context.setFillColor(UIColor.red.cgColor)
        
        let rectangel = CGRect(x: 70, y: 50, width: 200, height: 100)
        context.addRect(rectangel)
        context.fill(rectangel)
        context.strokePath()
        
        // Draw Circle
        context.setLineWidth(1.0)
        context.setStrokeColor(UIColor.blue.cgColor)
        context.setFillColor(UIColor.blue.cgColor)
        
        let circle = CGRect(x: 70, y: 200, width: 200, height: 100)
        context.addEllipse(in: circle)
        context.fillEllipse(in: circle)
        context.strokePath()
        
        // Draw Triangle
        context.setLineWidth(1.0)
        context.setStrokeColor(UIColor.green.cgColor)
        context.setFillColor(UIColor.green.cgColor)
        
        context.move(to: CGPoint(x: 170, y: 350))
        context.addLine(to: CGPoint(x: 270, y: 450))
        context.addLine(to: CGPoint(x:  70, y: 450))
        context.addLine(to: CGPoint(x: 170, y: 350))
        context.fillPath()
        context.strokePath()
        
        imgView.image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
    }
}


17 탭과 터치 사용해 스케치 앱 만들기

 

import UIKit

class ViewController: UIViewController {
    @IBOutlet var imgView: UIImageView! // 그림을 그릴 이미지 뷰

    var lastPoint: CGPoint!  // 마지막 터치 위치를 저장
    var lineSize: CGFloat = 5.0 // 선의 크기 (기본값 5.0)
    var lineColor = UIColor.black.cgColor // 선의 색 (기본값 검정색)

    override func viewDidLoad() {
        super.viewDidLoad()
        // 추가적인 설정이 필요할 경우 이곳에서 초기화 작업을 할 수 있습니다.
    }

    // '지우기' 버튼 액션
    @IBAction func btnClearImageView(_ sender: UIButton) {
        // 이미지 뷰의 이미지를 지웁니다. 즉, 그림판을 초기화합니다.
        imgView.image = nil
    }
    
    // 사용자가 화면을 터치하기 시작할 때 호출되는 메소드
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        let touch = touches.first! as UITouch  // 첫 번째 터치 포인트를 가져옵니다.
        
        // 터치 위치를 lastPoint에 저장하여, 그리기 시작 시 참고합니다.
        lastPoint = touch.location(in: imgView)
    }
    
    // 사용자가 화면을 터치하면서 이동할 때 호출되는 메소드
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        // 그리기 컨텍스트를 시작하여 이미지를 업데이트합니다.
        UIGraphicsBeginImageContext(imgView.frame.size)
        
        // 현재 컨텍스트의 속성을 설정합니다 (선 색, 선 끝 모양, 선 두께).
        UIGraphicsGetCurrentContext()?.setStrokeColor(lineColor)  // 선 색
        UIGraphicsGetCurrentContext()?.setLineCap(.round)  // 선 끝 모양을 둥글게
        UIGraphicsGetCurrentContext()?.setLineWidth(lineSize)  // 선 두께 설정
        
        // 터치된 위치를 가져옵니다.
        let touch = touches.first! as UITouch
        let currPoint = touch.location(in: imgView)  // 현재 터치 위치
        
        // 기존 이미지가 있으면 그리기 영역에 그립니다.
        imgView.image?.draw(in: CGRect(x: 0, y: 0, width: imgView.frame.size.width, height: imgView.frame.size.height))
        
        // 마지막 위치에서 현재 위치까지 선을 그립니다.
        UIGraphicsGetCurrentContext()?.move(to: CGPoint(x: lastPoint.x, y: lastPoint.y))
        UIGraphicsGetCurrentContext()?.addLine(to: CGPoint(x: currPoint.x, y: currPoint.y))
        UIGraphicsGetCurrentContext()?.strokePath()  // 경로를 그려서 선을 완성
        
        // 그린 이미지를 UIImage로 변환하여 imgView에 설정
        imgView.image = UIGraphicsGetImageFromCurrentImageContext()
        
        // 그리기 작업이 끝났으므로 그래픽 컨텍스트를 종료합니다.
        UIGraphicsEndImageContext()
        
        // 마지막 터치 위치를 업데이트하여, 그리기 작업이 계속 이어질 수 있도록 합니다.
        lastPoint = currPoint
    }
    
    // 사용자가 화면에서 손을 뗄 때 호출되는 메소드
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        // 터치가 끝났을 때 그리기 작업을 마무리하기 위한 컨텍스트 생성
        UIGraphicsBeginImageContext(imgView.frame.size)
        
        // 그리기 속성 설정 (선 색, 선 끝 모양, 선 두께)
        UIGraphicsGetCurrentContext()?.setStrokeColor(lineColor) 
        UIGraphicsGetCurrentContext()?.setLineCap(.round)  
        UIGraphicsGetCurrentContext()?.setLineWidth(lineSize)
        
        // 기존 이미지를 다시 그립니다.
        imgView.image?.draw(in: CGRect(x: 0, y: 0, width: imgView.frame.size.width, height: imgView.frame.size.height))
        
        // 마지막 위치에서 자신에게 선을 추가하는 방식으로 마무리
        UIGraphicsGetCurrentContext()?.move(to: CGPoint(x: lastPoint.x, y: lastPoint.y))
        UIGraphicsGetCurrentContext()?.addLine(to: CGPoint(x: lastPoint.x, y: lastPoint.y))  // 사실상 점으로 그려지며, 선을 마감
        UIGraphicsGetCurrentContext()?.strokePath()  // 경로를 그려서 선을 완성
        
        // 그린 이미지를 이미지 뷰에 할당
        imgView.image = UIGraphicsGetImageFromCurrentImageContext()
        
        // 그래픽 컨텍스트를 종료
        UIGraphicsEndImageContext()
    }
    
    // 기기 흔들림을 감지할 때 호출되는 메소드
    override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
        // 만약 기기가 흔들리면 이미지 뷰의 그림을 지웁니다.
        if motion == .motionShake {
            imgView.image = nil
        }
    }
}

 

소스 개선하기

import UIKit

class ViewController: UIViewController {
    @IBOutlet var imgView: UIImageView! // 사용자 그림을 그릴 이미지 뷰
    
    var lastPoint: CGPoint! // 마지막 터치 위치
    var lineSize: CGFloat = 5.0 // 선의 두께
    var lineColor = UIColor.black.cgColor // 선의 색상

    override func viewDidLoad() {
        super.viewDidLoad()
        // 뷰가 로드된 후 추가적인 설정을 수행합니다.
    }

    // 이미지 뷰를 지우는 버튼 클릭 시 호출되는 액션
    @IBAction func btnClearImageView(_ sender: UIButton) {
        imgView.image = nil // 이미지 뷰의 이미지를 nil로 설정하여 지움
    }
    
    // 터치가 시작될 때 호출되는 메서드
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        let touch = touches.first! as UITouch // 첫 번째 터치를 가져옴
        lastPoint = touch.location(in: imgView) // 마지막 터치 위치 저장
    }
    
    // 터치가 이동할 때 호출되는 메서드
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        // 새로운 이미지 컨텍스트 시작
        UIGraphicsBeginImageContext(imgView.frame.size)
        UIGraphicsGetCurrentContext()?.setStrokeColor(lineColor) // 선 색상 설정
        UIGraphicsGetCurrentContext()?.setLineCap(CGLineCap.round) // 선의 끝 모양 설정
        UIGraphicsGetCurrentContext()?.setLineWidth(lineSize) // 선의 두께 설정
        
        let touch = touches.first! as UITouch // 첫 번째 터치 가져오기
        let currPoint = touch.location(in: imgView) // 현재 터치 위치 저장
        
        // 기존 이미지를 현재 컨텍스트에 그리기
        imgView.image?.draw(in: CGRect(x: 0, y: 0, width: imgView.frame.size.width, height: imgView.frame.size.height))
        
        // 마지막 위치에서 현재 위치로 선 그리기
        UIGraphicsGetCurrentContext()?.move(to: CGPoint(x: lastPoint.x, y: lastPoint.y))
        UIGraphicsGetCurrentContext()?.addLine(to: CGPoint(x: currPoint.x, y: currPoint.y))
        UIGraphicsGetCurrentContext()?.strokePath() // 선 그리기
        
        // 새로 그린 이미지를 이미지 뷰에 설정
        imgView.image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext() // 이미지 컨텍스트 종료
        
        lastPoint = currPoint // 마지막 터치 위치 업데이트
    }
    
    // 터치가 끝날 때 호출되는 메서드
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        // 새로운 이미지 컨텍스트 시작
        UIGraphicsBeginImageContext(imgView.frame.size)
        UIGraphicsGetCurrentContext()?.setStrokeColor(lineColor) // 선 색상 설정
        UIGraphicsGetCurrentContext()?.setLineCap(CGLineCap.round) // 선의 끝 모양 설정
        UIGraphicsGetCurrentContext()?.setLineWidth(lineSize) // 선의 두께 설정
        
        // 기존 이미지를 현재 컨텍스트에 그리기
        imgView.image?.draw(in: CGRect(x: 0, y: 0, width: imgView.frame.size.width, height: imgView.frame.size.height))
        
        // 마지막 위치에서 현재 위치로 선 그리기
        UIGraphicsGetCurrentContext()?.move(to: CGPoint(x: lastPoint.x, y: lastPoint.y))
        UIGraphicsGetCurrentContext()?.addLine(to: CGPoint(x: lastPoint.x, y: lastPoint.y)) // 선을 끝점으로
        UIGraphicsGetCurrentContext()?.strokePath() // 선 그리기
        
        // 새로 그린 이미지를 이미지 뷰에 설정
        imgView.image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext() // 이미지 컨텍스트 종료
    }
    
    // 기기 흔들림 감지 시 호출되는 메서드
    override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
        if motion == .motionShake {
            imgView.image = nil // 흔들림이 감지되면 이미지 뷰를 지움
        }
    }
}

https://www.apple.com/kr/search/bmi?src=globalnav

 

bmi - Apple (KR)

 

www.apple.com

기존 앱스토어에 있는 BMI앱 분석하기

 

이 소스 코드에서는 iOS 개발에 필요한 몇 가지 중요한 문법과 기능들이 포함되어 있습니다.

이들을 정리하여 설명드리겠습니다. 각 기능이 어떻게 동작하는지, 그리고 왜 중요한지에 대해 알아보겠습니다.


### 1. **IBOutlet & IBAction**
**IBOutlet**과 **IBAction**은 인터페이스 빌더에서 UI 요소와 코드와의 연결을 위해 사용하는 중요한 키워드입니다.

- **IBOutlet**: UI 요소(버튼, 레이블, 텍스트 필드 등)와 코드 간의 연결을 생성하는 데 사용됩니다. `@IBOutlet`은 Interface Builder에서 해당 UI 요소를 코드와 연결할 때 사용됩니다.

  ```swift
  @IBOutlet weak var resultLabel: UILabel!  // 레이블을 IBOutlet으로 선언
  ```

- **IBAction**: UI 요소의 액션(예: 버튼 클릭)과 코드를 연결하는 데 사용됩니다. `@IBAction`은 UI 요소에서 발생하는 이벤트(버튼 클릭 등)가 트리거되었을 때 호출되는 메서드를 정의합니다.

  ```swift
  @IBAction func calculateBMI(_ sender: UIButton) { // 버튼 클릭 시 호출되는 함수
      // 함수 내용
  }
  ```

### 2. **guard 문**
**`guard`** 문은 조건이 참일 때만 코드 블록을 실행하도록 하는 조건문입니다. 주로 함수나 메서드 초기에 유효성 검사를 할 때 사용됩니다. 조건이 거짓일 경우 `else` 블록에서 특정 작업을 수행하고 함수에서 빠져나가게 됩니다.

```swift
guard let heightText = heightTextField.text, let weightText = weightTextField.text,
      let height = Double(heightText), let weight = Double(weightText) else {
    resultLabel.text = "잘못된 입력입니다. 다시 시도해주세요."
    return
}
```

- 이 코드는 `heightTextField`와 `weightTextField`에서 입력 받은 텍스트가 `Double`로 변환될 수 있는지 확인합니다. 변환이 실패하면 사용자에게 "잘못된 입력입니다"라는 메시지를 보여주고, 이후 코드 실행을 멈추도록 합니다.

### 3. **UserDefaults**
**`UserDefaults`**는 사용자 데이터를 로컬에 간단하게 저장하고 불러오는 데 사용하는 클래스입니다. 앱을 종료해도 저장된 데이터는 유지됩니다.

```swift
// BMI를 저장
UserDefaults.standard.set(savedBMI, forKey: "SavedBMI")

// 저장된 BMI를 불러오기
let savedBMI = UserDefaults.standard.array(forKey: "SavedBMI") as? [Double] ?? []
```

- `UserDefaults.standard.set(...)`으로 데이터를 저장하고, `UserDefaults.standard.array(forKey: ...)`로 데이터를 불러옵니다. 이 방식은 간단한 데이터(문자열, 숫자 등) 저장에 유용합니다.

### 4. **String Formatting**
**String formatting**은 숫자나 다른 값을 문자열로 변환할 때, 특정 형식을 지정하는 방법입니다. 예를 들어, BMI 값을 소수점 이하 두 자리로 표시할 때 사용합니다.

```swift
resultLabel.text = "BMI: \(String(format: "%.2f", bmi))"
```

- `String(format: "%.2f", bmi)`는 `bmi` 값을 소수점 이하 2자리로 포맷합니다. `%.2f`는 부동 소수점 숫자를 2자리로 출력하라는 형식 지정자입니다.

### 5. **조건문 (if, else if, else)**
조건문은 코드 흐름을 제어하는 데 필수적인 문법입니다. 특정 조건에 맞는 경우에만 특정 작업을 수행하도록 할 때 사용합니다.

```swift
if bmi < 18.5 {
    status = "저체중"
    advice = "체중을 늘리기 위한 식단과 운동을 고려해보세요."
} else if bmi >= 18.5 && bmi < 24.9 {
    status = "정상 체중"
    advice = "건강한 체중을 유지하고 있습니다!"
} else if bmi >= 25 && bmi < 29.9 {
    status = "과체중"
    advice = "건강한 체중을 유지하기 위해 운동과 식단 조절이 필요합니다."
} else {
    status = "비만"
    advice = "비만입니다. 체중을 줄이기 위한 전문가 상담을 고려하세요."
}
```

- 이 코드는 `bmi` 값에 따라 상태를 구분하고 그에 맞는 권장사항을 제공하는 예입니다. `if`, `else if`, `else`는 여러 조건을 순차적으로 비교하여 처리합니다.

### 6. **UITextField**
**`UITextField`**는 사용자가 텍스트를 입력할 수 있는 UI 요소입니다. 여기서는 BMI 계산에 필요한 키와 체중을 입력 받는 데 사용됩니다.

```swift
@IBOutlet weak var heightTextField: UITextField!
@IBOutlet weak var weightTextField: UITextField!
```

- `UITextField`의 `.text` 프로퍼티를 통해 사용자가 입력한 텍스트를 읽어오고, 이를 변환하여 숫자 계산을 진행합니다.

### 7. **IBAction에 매개변수 전달**
**`IBAction`** 메서드는 버튼 등 UI 요소에서 발생한 이벤트를 처리하는 메서드입니다. `sender` 매개변수는 해당 이벤트를 발생시킨 UI 요소를 가리킵니다. 이를 통해, 버튼의 종류나 상태에 따른 처리도 가능합니다.

```swift
@IBAction func calculateBMI(_ sender: UIButton) {
    // sender는 이벤트를 발생시킨 UIButton 객체입니다.
}
```

- 위 코드에서 `sender`는 클릭된 버튼 객체를 나타냅니다. 필요에 따라 버튼의 제목을 확인하거나 다른 작업을 수행할 수 있습니다.

### 8. **UILabel을 통한 UI 업데이트**
**`UILabel`**은 사용자에게 정보를 출력하는 데 사용되는 UI 요소입니다. `resultLabel.text`처럼 텍스트를 업데이트할 수 있습니다.

```swift
resultLabel.text = "BMI: \(String(format: "%.2f", bmi))"
```

- 이 코드는 `resultLabel`의 텍스트를 업데이트하여 BMI 계산 결과를 사용자에게 보여줍니다.

### 9. **배열과 String 합치기**
**배열**과 **문자열 합치기**를 통해 여러 값을 하나의 문자열로 만들 수 있습니다. 예를 들어, 저장된 BMI 값들을 하나의 문자열로 결합할 때 사용됩니다.

```swift
savedBMIListLabel.text = "저장된 BMI:\n" + savedBMI.map { String(format: "%.2f", $0) }.joined(separator: "\n")
```

- `map`은 배열의 각 요소를 변환하는 함수입니다. 이 예에서는 `savedBMI` 배열의 각 값을 소수점 2자리로 포맷한 뒤, 각 BMI 값을 `\n`으로 구분하여 하나의 문자열로 합칩니다.

---

### 요약

이 코드를 통해 **iOS 기본 UI 구성**, **사용자 입력 처리**, **조건문과 데이터 저장**, **UI 업데이트** 등의 중요한 기능을 다루고 있습니다. 각 문법을 차근차근 익히면 iOS 앱 개발에 필요한 기본적인 기술을 쌓을 수 있습니다. 여기서 다룬 주요 개념을 정리하면:

- **IBOutlet & IBAction**: UI 요소와 코드 연결
- **guard**: 조건 체크 및 조기 리턴
- **UserDefaults**: 간단한 데이터 저장
- **String formatting**: 문자열 형식화
- **if-else 조건문**: 조건에 따른 분기
- **UITextField**: 사용자 입력 받기
- **UILabel**: 텍스트 업데이트
- **배열 처리 및 문자열 결합**: 데이터를 배열로 처리하고, 문자열로 변환하기

이 기능들을 차근차근 학습하고 실제 앱에 적용해보면, iOS 개발에 중요한 기초를 다질 수 있습니다.

 

BMI 계산

let weight = 60.0
let height = 170.0
let bmi = weight / (height*height*0.0001) // kg/m*m
print(bmi)
import Foundation
let weight = 60.0
let height = 170.0
let bmi = weight / (height*height*0.0001) // kg/m*m
let shortenedBmi = String(format: "%.1f", bmi)
var body = ""
if bmi >= 40 {
    body = "3단계 비만"
} else if bmi >= 30 && bmi < 40 {
    body = "2단계 비만"
} else if bmi >= 25 && bmi < 30 {
    body = "1단계 비만"
} else if bmi >= 18.5 && bmi < 25 {
    body = "정상"
} else {
    body = "저체중"
}
print("BMI:\(shortenedBmi), 판정:\(body)")

앱 개발 절차

UI 디자인 확인 > 변수 : Outlet를 잡기 >  Connections inspector로 한번만 연결된 것인지 확인 > Action에 함수 '소스 코드' 작성

클래스의 프로퍼티 선언에 IBOutlet와 IBInspectable 사용

클래스의 메서드 선언에 IBAction과 IBSegueAction 사용

키보드 종류

'iOS 프로그래밍' 카테고리의 다른 글

iOS 프로그래밍_11주차  (0) 2024.11.27
iOS 프로그래밍_10주차  (0) 2024.11.20
iOS 프로그래밍_8주차  (0) 2024.11.06
iOS 프로그래밍_7주차  (0) 2024.10.30
iOS 프로그래밍_6주차  (0) 2024.10.16