MyAssets 코드리뷰(feat. SwiftUI)
✅ 공부를 했으니 코드 리뷰도 진행해보자.
SwiftUI를 보면서 든 생각인데, Obj-c가 swift로 넘어왔고, 언젠가는 SwiftUI로 전부 다 넘어가지 않을까 싶다.
우선 첫번째로, 선언형 프로그래밍 방식이 UI구성이 엄청나게 간단하고, 개발 속도가 엄청나게 빨라질 것 같다는 생각이 들었다.
SwiftUI를 Tutorial로 더 공부하고나서 코드 리뷰를 더 자세히 해보자~!
✅ 폴더의 구조
✅ ContentView.swift
//
// ContentView.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/22.
//
import SwiftUI
struct ContentView: View {
@State private var selection: Tab = .asset
enum Tab {
case asset
case recommend
case alert
case setting
}
var body: some View {
TabView(selection: $selection) { // 하단 탭바 아이템
AssetView()
.tabItem {
Image(systemName: "dollarsign.circle.fill")
Text("자산")
}
.tag(Tab.asset) // 태그를 달아준다.
Color.blue
.edgesIgnoringSafeArea(.all)
.tabItem {
Image(systemName: "hand.thumbsup.fill")
Text("추천")
}
.tag(Tab.recommend)
Color.red
.edgesIgnoringSafeArea(.all)
.tabItem {
Image(systemName: "bell.fill")
Text("알림")
}
.tag(Tab.alert)
Color.yellow
.edgesIgnoringSafeArea(.all)
.tabItem {
Image(systemName: "gearshape.fill")
Text("설정")
}
.tag(Tab.setting)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
✅ Asset 폴더
🟠 AssetView.swift
//
// AssetView.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/23.
//
import SwiftUI
struct AssetView: View {
var body: some View {
NavigationView { // 네비게이션 view
ScrollView { // 스유에서는 스크롤을 직접 달아줘야 한다.
VStack(spacing: 30) { // 각 컴포넌트간 spacing
Spacer()
AssetMenuGridView()
AssetBannerView()
.aspectRatio(5/2, contentMode: .fit)
AssetSummaryView()
.environmentObject(AssetSummaryData())
}
}
.background(Color.gray.opacity(0.2))
.navigaionBarWithButtonStyle("내 자산")
}
}
}
struct AssetView_Previews: PreviewProvider {
static var previews: some View {
AssetView()
}
}
🟠 AssetMenu.swift
//
// AssetMenu.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/22.
//
import Foundation
class Asset: Identifiable, ObservableObject, Decodable {
let id: Int
let type: AssetMenu
let data: [AssetData]
init(id: Int, type: AssetMenu, data: [AssetData]) {
self.id = id
self.type = type
self.data = data
}
}
class AssetData: Identifiable, ObservableObject, Decodable {
let id: Int
let title: String
let amount: String
let creditCardAmounts: [CreditCardAmounts]?
init(id: Int, title: String, amount: String, creditCardAmounts: [CreditCardAmounts]? = nil) {
self.id = id
self.title = title
self.amount = amount
self.creditCardAmounts = creditCardAmounts
}
}
enum CreditCardAmounts: Identifiable, Decodable {
case previousMonth(amount: String)
case currentMonth(amount: String)
case nextMonth(amount: String)
var id: Int {
switch self {
case .previousMonth:
return 0
case .currentMonth:
return 1
case .nextMonth:
return 2
}
}
var amount: String {
switch self {
case .previousMonth(let amount),
.currentMonth(let amount),
.nextMonth(let amount):
return amount
}
}
enum CodingKeys: String, CodingKey {
case previousMonth
case currentMonth
case nextMonth
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
if let value = try? values.decode(String.self, forKey: .previousMonth) {
self = .previousMonth(amount: value)
return
}
if let value = try? values.decode(String.self, forKey: .currentMonth) {
self = .previousMonth(amount: value)
return
}
if let value = try? values.decode(String.self, forKey: .nextMonth) {
self = .previousMonth(amount: value)
return
}
throw fatalError("ERROR: CreditCardAmount JSON Decoding")
}
}
enum AssetMenu: String, Identifiable, Decodable {
case creditScore
case bankAccount
case investment
case loan
case insurance
case creditCard
case cash
case realEstate
var id: String {
return self.rawValue
}
var systemImageName: String {
switch self {
case .creditScore:
return "number.circle"
case .bankAccount:
return "banknote"
case .investment:
return "bitcoinsign.circle"
case .loan:
return "hand.wave"
case .insurance:
return "lock.shield"
case .creditCard:
return "creditcard"
case .cash:
return "dollarsign.circle"
case .realEstate:
return "house.fill"
}
}
var title: String {
switch self {
case .creditScore:
return "신용점수"
case .bankAccount:
return "계좌"
case .investment:
return "투자"
case .loan:
return "대출"
case .insurance:
return "보험"
case .creditCard:
return "카드"
case .cash:
return "현금영수증"
case .realEstate:
return "부동산"
}
}
}
✅ Subcomponents 폴더
✅ NavigationBar 폴더
🟠 NavigationVarWithButton.swift
//
// NavigationVarWithButton.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/22.
//
import SwiftUI
struct NavigationBarWithButton: ViewModifier {
var title: String = ""
func body(content: Content) -> some View {
return content
.navigationBarItems(
leading: Text(title)
.font(.system(size: 24, weight: .bold))
.padding(),
trailing: Button(
action: {
print("자산추가버튼 tapped")
},
label: {
Image(systemName: "plus")
Text("자산추가")
.font(.system(size: 12))
}
)
.accentColor(.black)
.padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.black)
)
)
.navigationBarTitleDisplayMode(.inline)
.onAppear {
let appearance = UINavigationBarAppearance()
appearance.configureWithTransparentBackground()
appearance.backgroundColor =
UIColor(white: 1, alpha: 0.6)
UINavigationBar.appearance().standardAppearance = appearance
UINavigationBar.appearance().compactAppearance = appearance
UINavigationBar.appearance().scrollEdgeAppearance = appearance
}
}
}
extension View {
func navigaionBarWithButtonStyle(_ title: String) -> some View {
return self.modifier(NavigationBarWithButton(title: title))
}
}
struct NavigationBarWithButton_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
Color.gray.edgesIgnoringSafeArea(.all)
.navigaionBarWithButtonStyle("내 자산")
}
}
}
✅ MenuGridView.swift
🟠 AssetMenuGridView.swift
//
// AssetMenuGridView.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/23.
//
import SwiftUI
struct AssetMenuGridView: View {
let menuList: [[AssetMenu]] = [
[.creditScore, .bankAccount, .investment, .loan],
[.insurance, .creditCard, .cash, .realEstate]
]
var body: some View {
VStack(spacing: 20) {
ForEach(menuList, id: \.self) { row in
HStack(spacing: 10) {
ForEach(row) { menu in
Button("") {
print("\(menu.title)버튼 tapped")
}
.buttonStyle(AssetMenuButtonStyle(menu: menu))
}
}
}
}
}
}
struct AssetMenuGridView_Previews: PreviewProvider {
static var previews: some View {
AssetMenuGridView()
}
}
🟠 AssetMenuButtonStyle.swift
//
// AssetMenuButtonStyle.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/22.
//
import SwiftUI
struct AssetMenuButtonStyle: ButtonStyle {
let menu: AssetMenu
func makeBody(configuration: Configuration) -> some View {
return VStack {
Image(systemName: menu.systemImageName)
.resizable()
.frame(width: 30, height: 30)
.padding([.leading, .trailing], 10)
Text(menu.title)
.font(.system(size: 12, weight: .bold))
}
.padding()
.foregroundColor(.blue)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
struct AssetMenuButtonStyle_Previews: PreviewProvider {
static var previews: some View {
HStack(spacing: 24) {
Button("") {
print("")
}
.buttonStyle(AssetMenuButtonStyle(menu: .creditScore))
Button("") {
print("")
}
.buttonStyle(AssetMenuButtonStyle(menu: .bankAccount))
Button("") {
print("")
}
.buttonStyle(AssetMenuButtonStyle(menu: .creditCard))
Button("") {
print("")
}
.buttonStyle(AssetMenuButtonStyle(menu: .cash))
}
.background(Color.gray.opacity(0.2))
}
}
🟠 AssetBannerView.swift
//
// AssetBannerView.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/23.
//
import SwiftUI
struct AssetBannerView: View {
let bannerList: [AssetBanner] = [
AssetBanner(title: "공지사항", description: "추가된 공지사항을 확인하세요", backgroundColor: .red),
AssetBanner(title: "주말 이벤트", description: "주말 이벤트 놓치지 마세요", backgroundColor: .yellow),
AssetBanner(title: "깜짝 이벤트", description: "엄청난 이벤트에 놀라지 마세요", backgroundColor: .blue),
AssetBanner(title: "가을 프로모션", description: "가을...🍂", backgroundColor: .brown)
]
@State private var currentPage = 0
var body: some View {
let pages = bannerList.map { BannerCard(banner: $0) }
ZStack(alignment: .bottomTrailing) {
PageViewController(pages: pages, currentPage: $currentPage)
PageControl(numberOfPages: bannerList.count, currentPage: $currentPage)
.frame(width: CGFloat(pages.count * 18))
.padding(.trailing)
}
}
}
struct AssetBannerView_Previews: PreviewProvider {
static var previews: some View {
AssetBannerView()
.aspectRatio(5/2, contentMode: .fit)
}
}
🟠 AssetBanner.swift
//
// AssetBanner.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/23.
//
import UIKit
struct AssetBanner {
let title: String
let description: String
let backgroundColor: UIColor
}
🟠 BannerCard.swift
//
// BannerCard.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/23.
//
import SwiftUI
struct BannerCard: View {
var banner: AssetBanner
var body: some View {
Color(banner.backgroundColor)
.overlay(
VStack {
Text(banner.title)
.font(.title)
Text(banner.description)
.font(.subheadline)
}
)
}
}
struct BannerCard_Previews: PreviewProvider {
static var previews: some View {
BannerCard(banner: AssetBanner(title: "공지사항", description: "추가된 공지사항을 확인하세요", backgroundColor: .red))
.aspectRatio(5/2, contentMode: .fit)
}
}
🟠 AssetSummaryView.swift
//
// AssetSummaryView.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/23.
//
import SwiftUI
struct AssetSummaryView: View {
@EnvironmentObject var assetData: AssetSummaryData
var assets: [Asset] {
return assetData.assets
}
var body: some View {
VStack(spacing: 20) {
ForEach(assets) { asset in
switch asset.type {
case .creditCard:
AssetCardSectionView(asset: asset)
.frame(idealHeight: 250)
default:
AssetSectionView(assetSection: asset)
}
}
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding()
}
}
}
struct AssetSummaryView_Previews: PreviewProvider {
static var previews: some View {
ScrollView {
AssetSummaryView()
.environmentObject(AssetSummaryData())
.background(Color.gray.opacity(0.2))
}
}
}
🟠 AssetSummaryData.swift
//
// AssetSummaryData.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/23.
//
import SwiftUI
class AssetSummaryData: ObservableObject {
@Published var assets: [Asset] = load("assets.json")
}
func load<T: Decodable>(_ filename: String) -> T {
let data: Data
guard let file = Bundle.main.url(forResource: filename, withExtension: nil) else {
fatalError("Couldn't find \(filename) in main bundle.")
}
do {
data = try Data(contentsOf: file)
} catch {
fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
}
do {
let decoder = JSONDecoder()
return try decoder.decode(T.self, from: data)
} catch {
fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
}
}
🟠 assets.json
[
{
"id": 0,
"type": "creditScore",
"data": [
{
"id": 0,
"title": "신용점수",
"amount": "999점"
}
]
},
{
"id": 1,
"type": "bankAccount",
"data": [
{
"id": 0,
"title": "신한은행",
"amount": "42,000원"
},
{
"id": 1,
"title": "국민은행",
"amount": "9,263,000원"
},
{
"id": 2,
"title": "카카오뱅크",
"amount": "2,255,900원"
}
]
},
{
"id": 2,
"type": "investment",
"data": [
{
"id": 0,
"title": "카카오페이",
"amount": "5,003,370원"
},
{
"id": 1,
"title": "한국투자",
"amount": "5,675,236원"
}
]
},
{
"id": 3,
"type": "loan",
"data": [
{
"id": 0,
"title": "카카오뱅크",
"amount": "-67,333,000원"
},
{
"id": 1,
"title": "하나은행",
"amount": "-4,000,000,000원"
}
]
},
{
"id": 4,
"type": "insurance",
"data": [
{
"id": 0,
"title": "삼성화재",
"amount": "월 20,000원"
},
{
"id": 1,
"title": "한화손해보험",
"amount": "월 77,400원"
},
{
"id": 2,
"title": "메리츠화재보험",
"amount": "월 10,000원"
},
{
"id": 3,
"title": "롯데손해보험",
"amount": "월 84,900원"
}
]
},
{
"id": 5,
"type": "creditCard",
"data": [
{
"id": 0,
"title": "현대카드",
"amount": "0원",
"creditCardAmounts": [
{
"previousMonth": "10,000원"
},
{
"currentMonth": "45,000원"
},
{
"nextMonth": "100,400원"
}
]
},
{
"id": 1,
"title": "우리카드",
"amount": "9,000원",
"creditCardAmounts": [
{
"previousMonth": "40,000원"
},
{
"currentMonth": "95,000원"
},
{
"nextMonth": "2,150,400원"
}
]
}
]
},
{
"id": 6,
"type": "cash",
"data": [
{
"id": 0,
"title": "이번 달 사용금액",
"amount": "472,890원"
}
]
},
{
"id": 7,
"type": "realEstate",
"data": [
{
"id": 0,
"title": "한강현대아파트",
"amount": "16억 9천만원"
},
{
"id": 1,
"title": "여의도오피스텔",
"amount": "2억 9천만원"
}
]
}
]
🟠 AssetSectionViewHeaderView.swift
//
// AssetSectionViewHeaderView.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/23.
//
import SwiftUI
struct AssetSectionHeaderView: View {
let title: String
var body: some View {
VStack(alignment: .leading) {
Text(title)
.font(.system(size: 20, weight: .bold))
.foregroundColor(.accentColor)
Divider()
.frame(height: 2)
.background(Color.primary)
.foregroundColor(.accentColor)
}
}
}
struct AssetSectionHeaderView_Previews: PreviewProvider {
static var previews: some View {
AssetSectionHeaderView(title: "은행")
}
}
🟠 SwiftUIView.swift
//
// SwiftUIView.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/23.
//
import SwiftUI
struct AssetCardSectionView: View {
@State private var selectedTab = 1
@ObservedObject var asset: Asset
var assetData: [AssetData] {
return asset.data
}
var body: some View {
VStack {
AssetSectionHeaderView(title: asset.type.title)
TabMenuView(
tabs: ["지난달 결제", "이번달 결제", "다음달 결제"],
selectedTab: $selectedTab,
updated: .constant(.nextMonth(amount: "10000"))
)
TabView(
selection: $selectedTab,
content: {
ForEach(0...2, id: \.self) { i in
VStack {
ForEach(asset.data) { data in
HStack {
Text(data.title)
.font(.title3)
.foregroundColor(.secondary)
Spacer()
Text(data.creditCardAmounts![i].amount)
.font(.title2)
.foregroundColor(.primary)
}
Divider()
}
}
.tag(i)
}
}
)
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
}
.padding()
}
}
struct AssetCardSectionView_Previews: PreviewProvider {
static var previews: some View {
let asset = AssetSummaryData().assets[5]
AssetCardSectionView(asset: asset)
}
}
🟠 AssetMenuList.swift
//
// AssetMenuList.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/23.
//
import SwiftUI
struct TabMenuView: View {
var tabs: [String]
@Binding var selectedTab: Int
@Binding var updated: CreditCardAmounts?
var body: some View {
HStack {
ForEach(0..<tabs.count, id: \.self) { row in
Button(
action: {
selectedTab = row
UserDefaults.standard.set(true, forKey: "creditcard_update_checked: \(row)")
},
label: {
VStack(spacing: 0) {
HStack {
if updated?.id == row {
let checked = UserDefaults.standard.bool(forKey: "creditcard_update_checked: \(row)")
Circle()
.fill(
!checked
? Color.red
: Color.clear
)
.frame(width: 6, height: 6)
.offset(x: 2, y: -8)
} else {
Circle()
.fill(Color.clear)
.frame(width: 6, height: 6)
.offset(x: 2, y: -8)
}
Text(tabs[row])
.font(.system(size: 20, weight: .bold))
.foregroundColor(
selectedTab == row
? .accentColor
: .gray
)
.offset(x: -4, y: 0)
}
.frame(height: 52)
Rectangle()
.fill(selectedTab == row
? Color.secondary
: Color.clear)
.frame(height: 3)
.offset(x: 4, y: 0)
}
})
.buttonStyle(PlainButtonStyle())
}
}
.frame(height: 55)
}
}
struct TabMenuView_Previews: PreviewProvider {
static var previews: some View {
TabMenuView(tabs: ["지난달 결제", "이번달 결제", "다음달 결제"], selectedTab: .constant(1), updated: .constant(.currentMonth(amount: "10,000원")))
}
}
🟠 AssetSectionView.swift
//
// AssetSectionView.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/23.
//
import SwiftUI
struct AssetSectionView: View {
@ObservedObject var assetSection: Asset
var body: some View {
VStack(spacing: 20) {
AssetSectionHeaderView(title: assetSection.type.title)
ForEach(assetSection.data) { asset in
HStack {
Text(asset.title)
.font(.title3)
.foregroundColor(.secondary)
Spacer()
Text(asset.amount)
.font(.title2)
.foregroundColor(.primary)
}
Divider()
}
}
.padding()
}
}
struct AssetSectionView_Previews: PreviewProvider {
static var previews: some View {
let asset = Asset(
id: 0,
type: .bankAccount,
data: [
AssetData(id: 0, title: "신한은행", amount: "5,300,000원"),
AssetData(id: 1, title: "국민은행", amount: "700,000원"),
AssetData(id: 2, title: "카카오뱅크", amount: "112,900,000원")
]
)
AssetSectionView(assetSection: asset)
}
}
🟠 PageViewController.swift
//
// PageViewController.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/23.
//
import SwiftUI
import UIKit
struct PageViewController<Page: View>: UIViewControllerRepresentable {
var pages: [Page]
@Binding var currentPage: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator
pageViewController.delegate = context.coordinator
return pageViewController
}
func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[context.coordinator.controllers[currentPage]], direction: .forward, animated: true)
}
class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageViewController
var controllers = [UIViewController]()
init(_ pageViewController: PageViewController) {
parent = pageViewController
controllers = parent.pages.map { UIHostingController(rootView: $0) }
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let index = controllers.firstIndex(of: viewController) else { return nil }
if index == 0 {
return controllers.last
}
return controllers[index - 1]
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let index = controllers.firstIndex(of: viewController) else { return nil }
if index + 1 == controllers.count {
return controllers.first
}
return controllers[index + 1]
}
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed,
let visibleViewController = pageViewController.viewControllers?.first,
let index = controllers.firstIndex(of: visibleViewController) {
parent.currentPage = index
}
}
}
}
🟠 PageControl.swift
//
// PageControl.swift
// MyAssets
//
// Created by Hamlit Jason on 2022/02/23.
//
import SwiftUI
import UIKit
struct PageControl: UIViewRepresentable {
var numberOfPages: Int
@Binding var currentPage: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages
control.addTarget(
context.coordinator,
action: #selector(Coordinator.updateCurrentPage(sender:)),
for: .valueChanged
)
return control
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
}
class Coordinator: NSObject {
var control: PageControl
init(_ control: PageControl) {
self.control = control
}
@objc func updateCurrentPage(sender: UIPageControl) {
control.currentPage = sender.currentPage
}
}
}
'Archive > 패캠(초격차)' 카테고리의 다른 글
part5 (ch1). FindCVS UnitTest 코드리뷰 (feat. Stubber) (0) | 2022.02.20 |
---|---|
part5 (ch1). FindCVS 코드리뷰 (0) | 2022.02.20 |
part5 (ch6). KeywordNews XCTest 코드리뷰 (0) | 2022.02.17 |
part5 (ch6). KeywordNews 코드리뷰 (0) | 2022.02.15 |
part5 (ch6). 🪛 CI/CD란? (feat. bitrise) (0) | 2022.02.15 |