[iOS] 스톱워치 앱 만들기 (4) - UIEditMenuInteraction과 UIPasteboard

2023. 7. 7. 21:15UIKit, H.I.G

728x90
 

[iOS] 스톱워치 앱 만들기 (3) - 테이블 뷰에 랩 타임 추가하기

[iOS] 스톱워치 앱 만들기 (2) - 버튼 클릭에 따른 상태 변화 기능 구현 [iOS] 스톱워치 앱 만들기 (1) - 프로젝트 기본 세팅과 Timer에 대한 이해 오랜만에 써보는 iOS 개발 글이다. 오지 않을 것만 같던

mini-min-dev.tistory.com

이번 글에서는 내가 스톱워치 앱을 만들기로 한 가장 주된 이유인, "Copy Paste 기능"을 구현해 보도록 하겠다.

갤럭시에서는 테이블 뷰에 랩 타임이 나와있으면 이를 쉽게 복사해서 기록을 공유할 수 있는 것에 반해,
아이폰 스톱워치에는 그 기능이 없어 내가 실제로 군대에서 어려움을 겪었다. (스톱워치를 사용할 때마다 갤럭시 찾기....)
그게 답답했던 나는 이번 기회에 그 기능을 탑재한 스톱워치를 (공부할 겸) 만들어보기로 한 거다.

<내가 생각한 스톱워치 앱 프로세스>

1. 우측 Start 버튼을 누르면 시간이 움직인다. 처음 상태에서 왼쪽 Lap 버튼은 눌릴 수 없다.
2. 시간이 가기 시작하면, Start 버튼은 Reset 버튼으로 바뀌고 Lap 버튼은 누를 수 있도록 바뀐다.
3. Lap 버튼을 누르면 아래 테이블 뷰에 Lap 타임이 순서대로 추가돼야 한다.
4. 테이블 뷰에 추가되어 있는 Lap 타임은 <Lap, 기록, 앞 Lap과의 차이> 순으로 표출되어야 하며, Copy와 Paste가 가능해야 한다.(아이폰에는 없고, 갤럭시에는 있는 기능. 이것이 내가 이번 앱을 개발하기로 한 가장 큰 이유이다.)
5. Reset 버튼을 누르면 시간 라벨과 테이블 뷰는 모두 초기화되어야 하며, 버튼도 (1)과 같은 상태로 돌아간다.


처음에 생각했던 방향은 아래 사진과 같았다.
랩 타임 기록이 나와있는 테이블 뷰를 사용자가 클릭하게 되면 아래와 같은 말풍선(?) 창이 나타나게 되고,
Copy를 누르면 "1    00:00.00(랩 타임)    00:00.00(앞 기록과의 차이)" 순으로 쭉쭉쭉... 나타나는 방식.

 

저 말풍선 창의 정확한 이름은 UIMenuController였다.
그리고 개발자 공식문서와 블로그를 보면서 기능을 구현하려는 그 순간. 나타난 친절한 Xcode의 한 문장.

"UIMenuController was deprecated in iOS 16.0"
해석하면, "UIMenuController는 iOS 16.0에서 더 이상 사용되지 않습니다."
오잉??? iOS 16부터는 UIMenuController를 대신해서 UIEditMenuInteraction이라는 새로운 기능을 지원한다는 내용이었다.

오히려 좋아.
이 참에 새로운 기능을 먼저 공부해보는거지.

 

UIEditMenuInteraction | Apple Developer Documentation

An interaction that provides edit operations using a menu.

developer.apple.com

공식문서에 나와있는 코드를 살펴봤다. 생각보다 하나도 어렵지가 않다.

제스처 인식기를 만들고, 표출될 Edit menu 개체를 만들고, 뷰의 위치를 받아 action과 연결해 주면 되는 간단한 코드잖아?
주어지는 4개의 델리게이트 함수로 구체적인 작업도 수행할 수 있다. 
이 중에서 menuFor 함수를 이용해서 UIMenu 타입의 메뉴 내용을 추가할 수 있다.

이제 내가 필요한 코드를 받아 만들어보겠다.

공식문서 상의 UIEditMenuInteraction 기본 코드
UIEditMenuInteraction이 제공해주는 4개의 델리게이트 함수들이다.


우선 나는 복사 기능만 사용할 것이고,

길게 꾹 누르는 LongPress가 아니라 기본적인 테이블 뷰의 클릭 상태에서 구현을 할 것이기 때문에 별도의 제스처 함수는 만들지 않았다.
기본적으로 TableViewDelegate에서 제공하는 didSelectRowAt 함수에서 사용자의 클릭을 받아줄 것이기 때문이다

copyMenuInteraction이라는 UIEditMenuInteraction 객체를 만들어준다.
기능을 담을 delegate는 밑에서 따로 익스텐션으로 빼주도록 하겠고, 테이블 뷰 안에 addInteraction으로 뷰를 담아준다.

그리고 위치를 담을 UIEditMenuConfiguration 객체도 만들겠다.
메뉴가 표출될 위치는 tableView의 자유롭게 담아주면 되겠고 (나는 코드에서 accessibilityActivationPoint를 기준으로 담았지만, 나중에 디자인 부분을 한 번에 만질 때 다시 수정할 계획이다.)
최종적으로 copyMenuInteraction에 presentEditMenu를 이용해서 화면에 표출시켜 주는 방식이다.

// MARK: - TableView Delegate
extension StopWatchViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let copyMenuInteraction = UIEditMenuInteraction(delegate: self)
        tableView.addInteraction(copyMenuInteraction)
        
        let configuration = UIEditMenuConfiguration(identifier: nil, sourcePoint: tableView.accessibilityActivationPoint)
        copyMenuInteraction.presentEditMenu(with: configuration)
    }
}

 

이제 델리게이트 함수를 받아올 차례!

이때, 여기서 델리게이트 안에는 위에서 말했던  "1    00:00.00(랩 타임)    00:00.00(앞 기록과의 차이)" 순으로
복사가 되는 기능이 들어갈 것이다.
복사는 UIPasteboard라는 기능이 사용된다.
사용방법은 매우 간단. 따로 설명할 것도 없고 그냥 아래 코드에서 주석으로 표시했으니 바로 확인하자.

 

UIPasteboard | Apple Developer Documentation

An object that helps a user share data from one place to another within your app, and from your app to other apps.

developer.apple.com

기록을 복사하는 내용은
앞선 2023.07.06 - [Swift x iOS] - [iOS] 스톱워치 앱 만들기 (3) - 테이블 뷰에 랩 타임 추가하기 에서 기록을 불러왔던 것을 참고했다.

간단하게 설명하자면, copyBoard라는 String Array를 별도로 만들어서
랩 데이터들을 반복하며 lapTableViewData의 indexNum을 이용해서 Array에 추가를 시켜주는 방법을 사용했다고 생각하면 된다.

// MARK: - UIEditMenuInteraction Delegate
extension StopWatchViewController: UIEditMenuInteractionDelegate {
    func editMenuInteraction(_ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration, suggestedActions: [UIMenuElement]) -> UIMenu? {
        let copyAction = UIAction(title: "기록 복사하기") {_ in
            var copyBoard: [String] = []
            
            for indexNum in 0...self.lapTableviewData.count-1 {
                copyBoard.append("\(indexNum+1)   \(self.lapTableviewData[indexNum])   \(self.diffTableViewData[indexNum])")
            }
            // 이 부분이 copyBoard Array의 내용을 복사하는 한 줄짜리 매우 간단한 코드이다.
            UIPasteboard.general.strings = copyBoard
        }
        return UIMenu(children: [copyAction])
    }
}

여기까지 하면 아래와 같은 기능이 구현가능해진다!

 

최종 스톱워치 뷰컨트롤러 풀코드를 확인해 보자.
생각보다 쉽게 만들었는데, 스톱워치 레이아웃도 다시 잡아보고 이참에 타이머까지 한번 개발해 볼까...?

//
//  StopWatchViewController.swift
//  WorkOut-Time-iOS
//
//  Created by 민 on 2023/07/04.
//

import UIKit

class StopWatchViewController: UIViewController {
    
    // MARK: - Properties
    private let mainStopwatch: Stopwatch = Stopwatch()
    private let lapStopwatch: Stopwatch = Stopwatch()
    private var isPlay: Bool = false
    private var lapTableviewData: [String] = []
    private var diffTableViewData: [String] = []
    
    // MARK: - @IBOutlet Properties
    @IBOutlet weak var stopWatchTableView: UITableView!
    @IBOutlet weak var timeLabel: UILabel!
    @IBOutlet weak var lapTimeLabel: UILabel!
    @IBOutlet weak var lapResetButton: UIButton!
    @IBOutlet weak var startPauseButton: UIButton!
    
    // MARK: - View Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        
        lapResetButton.isEnabled = false
        
        stopWatchTableView.register(StopWatchTableViewCell.nib(), forCellReuseIdentifier: Const.Xib.stopWatchTableViewCell)
        
        stopWatchTableView.delegate = self
        stopWatchTableView.dataSource = self
        
        
    }
    
    // MARK: - @IBAction Properties
    @IBAction func lapResetTime(_ sender: Any) {
        
        // 시간이 멈추어져 있을 때 (버튼은 Reset으로 클릭 가능하도록 표출) -> 상황은 Reset을 눌렀을 때의 코드임
        if !isPlay {
            resetMainTimer()
            resetLapTimer()
            changeButton(lapResetButton, title: "Lap", titleColor: UIColor.lightGray)
            lapResetButton.isEnabled = false
        }
        
        // 시간이 가고 있을 때 (버튼은 Lap으로 클릭 가능하도록 표출) -> 상황은 Lap을 눌렀을 때의 코드임
        else {
            if let timerLabelText = timeLabel.text {
                lapTableviewData.append(timerLabelText)
            }
            if let diffLabelText = lapTimeLabel.text {
                diffTableViewData.append(diffLabelText)
            }
            
            stopWatchTableView.reloadData()
            resetLapTimer()
            unowned let weakSelf = self
            lapStopwatch.timer = Timer.scheduledTimer(timeInterval: 0.01, target: weakSelf, selector: Selector.updateLapTimer, userInfo: nil, repeats: true)
            RunLoop.current.add(lapStopwatch.timer, forMode: RunLoop.Mode.common)
        }
    }
    
    @IBAction func startPauseTime(_ sender: Any) {
        lapResetButton.isEnabled = true
        changeButton(lapResetButton, title: "Lap", titleColor: UIColor.black)
        
        // 시간이 멈추어져 있을 때 (버튼은 Start로 표출) -> 상황은 Start를 눌렀을 때의 코드임
        if !isPlay {
            unowned let weakSelf = self
            
            mainStopwatch.timer = Timer.scheduledTimer(timeInterval: 0.01, target: weakSelf, selector: Selector.updateMainTimer, userInfo: nil, repeats: true)
            lapStopwatch.timer = Timer.scheduledTimer(timeInterval: 0.01, target: weakSelf, selector: Selector.updateLapTimer, userInfo: nil, repeats: true)
            
            RunLoop.current.add(mainStopwatch.timer, forMode: RunLoop.Mode.common)
            RunLoop.current.add(lapStopwatch.timer, forMode: RunLoop.Mode.common)
            
            isPlay = true
            changeButton(startPauseButton, title: "Stop", titleColor: UIColor.red)
        }
        
        // 시간이 가고 있을 때 (버튼은 Stop으로 표출) -> 상황은 Stop을 눌렀을 때의 코드임
        else {
            mainStopwatch.timer.invalidate()
            lapStopwatch.timer.invalidate()
            isPlay = false
            changeButton(startPauseButton, title: "Start", titleColor: UIColor.green)
            changeButton(lapResetButton, title: "Reset", titleColor: UIColor.black)
        }
    }
}

// MARK: - Action Functions
extension StopWatchViewController {
    
    func resetTimer(_ stopwatch: Stopwatch, label: UILabel) {
        stopwatch.timer.invalidate()
        stopwatch.counter = 0.0
        label.text = "00:00:00"
    }
    
    func resetMainTimer() {
        resetTimer(mainStopwatch, label: timeLabel)
        lapTableviewData.removeAll()
        stopWatchTableView.reloadData()
    }
    
    func resetLapTimer() {
        resetTimer(lapStopwatch, label: lapTimeLabel)
    }
    
    @objc func updateMainTimer() {
        updateTimer(mainStopwatch, label: timeLabel)
    }
    
    @objc func updateLapTimer() {
        updateTimer(lapStopwatch, label: lapTimeLabel)
    }
    
    func updateTimer(_ stopwatch: Stopwatch, label: UILabel) {
        stopwatch.counter = stopwatch.counter + 0.01
        
        var minutes: String = "\((Int)(stopwatch.counter / 60))"
        if (Int)(stopwatch.counter / 60) < 10 {
            minutes = "0\((Int)(stopwatch.counter / 60))"
        }
        
        var seconds: String = String(format: "%.2f", (stopwatch.counter.truncatingRemainder(dividingBy: 60)))
        if stopwatch.counter.truncatingRemainder(dividingBy: 60) < 10 {
            seconds = "0" + seconds
        }
        
        label.text = minutes + ":" + seconds
    }
}

// MARK: - TableView Delegate
extension StopWatchViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let copyMenuInteraction = UIEditMenuInteraction(delegate: self)
        tableView.addInteraction(copyMenuInteraction)
        
        let configuration = UIEditMenuConfiguration(identifier: nil, sourcePoint: tableView.accessibilityActivationPoint)
        copyMenuInteraction.presentEditMenu(with: configuration)
    }
}

// MARK: - TableView DataSource
extension StopWatchViewController: UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return lapTableviewData.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let timeCell = tableView.dequeueReusableCell(withIdentifier: Const.Xib.stopWatchTableViewCell, for: indexPath) as? StopWatchTableViewCell else { return UITableViewCell() }
        
        let lap = lapTableviewData.count - (indexPath as NSIndexPath).row
        
        // Lap 순위
        if let lapLabel = timeCell.lapLabel {
            lapLabel.text = "Lap \(lap)"
        }
        
        // 실제 기록
        if let recordLabel = timeCell.recordLabel {
            recordLabel.text = "\(lapTableviewData[lap-1])"
        }
        
        // 앞선 기록과의 차이
        if let diffLabel = timeCell.diffLabel {
            diffLabel.text = "\(diffTableViewData[lap-1])"
        }
        return timeCell
    }
}

// MARK: - UIEditMenuInteraction Delegate
extension StopWatchViewController: UIEditMenuInteractionDelegate {
    func editMenuInteraction(_ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration, suggestedActions: [UIMenuElement]) -> UIMenu? {
        let copyAction = UIAction(title: "기록 복사하기") {_ in
            var copyBoard: [String] = []
            
            for indexNum in 0...self.lapTableviewData.count-1 {
                copyBoard.append("\(indexNum+1)   \(self.lapTableviewData[indexNum])   \(self.diffTableViewData[indexNum])")
            }
            UIPasteboard.general.strings = copyBoard
        }
        return UIMenu(children: [copyAction])
    }
}

// MARK: - Selector Extension
fileprivate extension Selector {
    static let updateMainTimer = #selector(StopWatchViewController.updateMainTimer)
    static let updateLapTimer = #selector(StopWatchViewController.updateLapTimer)
}
728x90