계산기 앱 만들기 chapter.05 : code base UIButton에 systemImage 적용하기

Emily·2024년 11월 10일
1

CalculatorApp

목록 보기
5/11

계산기 앱 프로젝트를 생성한 지 2주가 되었다. 부트캠프에 합류한 상태라서 개인 프로젝트보다 캠프 내 학습과 과제가 우선이다보니 계속해서 우선순위에서 밀려났었다. 사실 기초 주차 때 플레이그라운드 과제가 너무 간단해서 시간이 남을테니까 병행하자는 건방진 생각으로 시작했다가 이렇게 됐다. 당연히 사칙연산 자체만 구현하고 나면 시간이 남았던 건 맞다. 하지만 과제의 의도는 그게 다가 아니었다. 객체 지향 어쩌구..를 생각하며 이해하고 적용하는 과정에서 대가리가 깨졌다.

하여튼 지난주에는 숫자야구 만드느라 프로젝트를 건드릴 생각도 못했는데, 이번 주말에 오랜만에 계산기 프로젝트 파일을 더블클릭 했다. 아 맞다, 그러던 와중에 나의 엉거주춤 프로젝트에 동료가 합류했다. 우리의 첫번째 역할 분담은 버튼 기능 구현 나눠 맡기였는데, 동기가 사칙연산 기능을 다 마친 동안 나는 AC (all clear) 버튼 하나 했다 하나. (자랑 아님.)

01) AC / CE 버튼 전환, inout 파라미터

계산기에서 입력값을 다 지우는 버튼인 AC 버튼은 사용자가 입력을 시작하는 순간 CE (clear entry) 버튼으로 전환된다. 키보드로 따지면 backspace 버튼이다. 한 글자만 지우는 거다. 그리고 =를 눌러 연산이 완료되어 결과값이 출력되는 순간 다시 AC로 돌아온다.

나는 창의력이 없기 때문에 일단 iOS 기본 제공 계산기 앱을 최대한 따라 만들어보고 있는데, 그 화면을 참고하면 clear 버튼의 모습은 이렇다.

여기서 clear entry 버튼의 이미지는 SF Symbols에서 제공하여 UIImage(systemName: "delete.left")로 쓸 수 있다.

저걸 따라해보려고 SF Symbols를 찾아보다보니, clear entry 버튼 뿐만 아니라 다른 +/-, %, 그리고 저 나누기 모양부터 사칙연산 버튼 모두 systemImage를 이용하여 구현할 수 있다는 것을 알게 됐다. 그래서 나중에 전부 이미지 적용을 하기로 마음을 먹고 일단은 버튼 전환/기능 구현부터 했다.

let defaultAction = UIAction(handler: { _ in })

switch buttonName {
    case .allClear:
        return UIAction { [weak self] _ in self?.text = "0" }
    case .clearEntry:
        return defaultAction
    case .plusMinus:
    	return defaultAction
    case .percent:
        return defaultAction
    default:
        return defaultAction
    }

이건 기존 코드다. 설명을 하자면 switch 구문을 이용해 button에 따라 다른 UIAction을 반환하여 button.addAction에 전송하는 것이다. 현재 all clear를 제외하고는 구현 전이라 모두 빈 상태라는 걸 알 수 있다. clear entry 버튼은 다음과 같이 구현했다.

case .clearEntry:
	return UIAction { [weak self] _ in
        guard var modifiedText = self?.text else { return } // 현재 text 복사
        self?.service.clearEntry(&modifiedText)	// service.clearEntry 함수 호출
        self?.text = modifiedText // 변경된 text를 view와 연결된 String 변수에 주입
	}

// service.clearEntry 코드
func clearEntry(_ text: inout String) {
	text.removeLast()
}

여기서 사실 clearEntry 함수에서 String을 반환하여 self?.text에 바로 주입할 수도 있었을 것이다. 근데 저렇게 구현한 이유는 내가 inout 키워드를 사용해보고 싶기도 했고, -> String을 하면 코드가 길어진다.

// for example,
func clearEntry(_ text: String) {
	// text.removeLast()는 바로 못한다. 파라미터는 immutable이기 때문이다.
    var value = text
    value.removeLast()
    return value
}

더 위에 내가 구현한 service.clearEntry보다 두 줄이 더 많은 것이 보이는가? 물론 저걸 쓰면 UIAction 클로저 안에서는 한 줄이 줄어들긴 할 것이다. 거의 거기서 거기다. 하지만 그냥 inout을 최근에 처음 알게 되어 써보고 싶었다🤭. inout 파라미터는 함수에 전달 시 &를 붙여야 한다. 일반 함수에서는 파라미터 자체의 값 변경이 불가능하다. 하지만 inout 파라미터는 그걸 해준다.

하여튼 저렇게 한 칸을 지워주는 동작을 구현한 뒤, 한 버튼의 자리에 AC 버튼을 노출할지, CE 버튼을 노출할지를 구분하는 기준이 될 Bool 타입 변수를 선언한 뒤 그것을 구독하여 버튼이 전환되도록 구현했다. (Combine 사용)

func toggleClearButton() {
	mainVM.$showAC
    	.sink { [weak self] showAC in
            self?.allClearButton.isHidden = !showAC
            self?.clearEntryButton.isHidden = showAC
        }
        .store(in: &cancellables)
}

02) UIButton에 systemImage 적용

기존 화면이다. 연산 버튼이 못생겨 보일 것이다. 모든 버튼에 setTitleString을 통해 적용했기 때문이다. 숫자 버튼은 그대로 문자열 적용을 유지했지만(setTitle("7", ...)), 연산 버튼과 같이 SF Symbols를 통해 systemImage를 적용할 수 있는 버튼들은 이미지를 사용하도록 변경했다.

// Button class에 Bool 프로퍼티 추가
// image를 사용할 button일 경우 true, title string을 사용할 경우는 false
let withImage: CurrentValueSubject<Bool, Never> = .init(false)

// ButtonArea class에 button 선언 시 bool 값 전송
// default를 false로 했기 때문에 image 쓸 button에서만 true를 전송하면 된다.
// >> AC button : "AC" string 사용하기 때문에 전송 X
private lazy var allClearButton: Button = {
    let button = Button()
    button.setButton(.init(role: .modifier, name: .allClear))
    return button
}()

// >> CE button : backspace image 사용할 거라서 true 전송
private lazy var clearEntryButton: Button = {
    let button = Button()
    button.withImage.send(true)	// bool 전송
    button.setButton(.init(role: .modifier, name: .clearEntry))
    return button
}()

// Button class에서 withImage를 구독하여 경우에 따라 적용한 모습
func setButton(_ buttonInfo: ButtonInfo) {
    withImage
        .sink { [weak self] withImage in
            if withImage {
                self?.setImage(buttonInfo.name.systemName)
                // >> image 적용하는 함수 밑에 첨부하고 설명할 것
                self?.tintColor = .white
            } else {
                self?.setTitle(buttonInfo.name.title, for: .normal)
                self?.setTitleLayout()
            }
        }
        .store(in: &cancellables)
}

setTitlesetImage를 구분 적용한 부분이다. 그런데 이 때, 이미지의 크기를 지정해주지 않으면 기본적으로 이미지가 굉장히 작게 적용된다. 그래서 이미지 크기를 따로 configuration을 통해 지정해주어야 하는데, auto layout을 위해 이것도 Combine으로 screen size를 구독하여 적용해야 했다.

private func setImage(_ systemName: String) {
	// imageSize : screen size에 따라 계산된 값을 전송 받은 publisher
	imageSize
        .sink { [weak self] size in
            let imageConfig = UIImage.SymbolConfiguration(pointSize: size, weight: .regular)
            self?.setImage(UIImage(systemName: systemName, withConfiguration: imageConfig), for: .normal)
        }
        .store(in: &cancellables)
}

처음에 단순히 setImage(UIImage(systemName: "", for: .normal)만 적용했다가 이미지가 너무 작아서 크기를 조절하려고 이것저것 삽질하며 시도했다가 SymbolConfiguration이라는 것을 발견했다. pointSize에 값을 전달해주면 크기가 조절되고, weightUILabel.textfontWeight처럼 systemImage의 선 굵기를 지정할 수 있다.

이렇게 적용한 화면은 아래와 같다.

한결 눈이 편해졌다.

아직 구현할 동작과 신경써서 적용해야하는 부분들이 많이 남았다. 시간이 예상보다 오래 걸릴 거 같지만, 그래도 끝까지 만들어보고 싶다.

profile
iOS Junior Developer

4개의 댓글

comment-user-thumbnail
2024년 11월 10일

오 계산기 놓지 않으셨군요
무 버튼 가져가주세요

2개의 답글