안녕하세요 요즘 블로그 활동이 뜸했는데요...^^ 그동안 이것저것 프로젝트 하면서 살았답니다 ㅎㅎ

제가 스위프 앱 1기에서 진행한 near 앱을 소개하겠습니다!

바쁜 현대 사회에서 소중한 친구들과의 관계를 놓치지 않고 더 가깝게 이어갈 수 있도록 돕는 것을 목표로 iOS 앱 'near'를 개발했습니다.

이번 포스팅에서는 'near' 앱 개발에 사용된 핵심 기술 스택, 아키텍처 결정 과정, 그리고 개발 중 겪었던 도전과 배움의 순간들을 공유하고자 합니다.

1. 왜 'near'인가? : 앱 개발 배경 및 목표

"연락해야 하는데..." 생각만 하고 놓쳐버린 친구, 있지 않으신가요? 'near'는 이런 아쉬움을 해결하기 위해 탄생했습니다. 저희는 사용자가 친구들의 생일, 기념일, 설정한 연락 주기 등을 놓치지 않고 관리하며, 꾸준하고 의미 있는 관계를 유지할 수 있도록 돕는 것을 목표로 삼았습니다.

주요 기능은 다음과 같습니다.

  • 연락처 및 카카오톡 친구 연동: 손쉽게 친구 목록을 가져와 관리합니다.
  • 맞춤 연락 주기 설정: 친구별로 원하는 연락 주기를 설정하고 리마인더를 받습니다.
  • 챙김 기록 및 관리: 친구를 챙긴 기록을 남기고, 관계 유지 현황을 파악합니다.
  • 홈 화면 대시보드: 이번 달 챙겨야 할 친구와 전체 친구 목록을 한눈에 확인합니다.

2. 기술 스택 Deep Dive: 'near'를 구성하는 핵심 기술들

안정적이면서도 확장 가능하고, 효율적인 개발 경험을 위해 신중하게 기술 스택을 선택했습니다.

2.1. 프로젝트 관리 및 구조화: Tuist

  • 선택 이유: Xcode 프로젝트 파일(.xcodeproj, .xcworkspace)의 복잡성과 충돌 가능성을 줄이고, 프로젝트 설정을 코드로 명확하게 관리하기 위해 Tuist를 도입했습니다.
    • Project.swift 파일을 통해 앱 타겟, 테스트 타겟, 설정, 종속성, 리소스 등을 정의했습니다.
    • 모듈화된 구조를 쉽게 구현하고, 팀원 간 설정 일관성을 유지하며 빌드 시간을 단축하는 효과를 얻었습니다.
    • .xcconfig 파일을 활용하여 Debug/Release 환경별 설정을 분리하고, KAKAO_APP_KEY, DEV_BASE_URL 등 민감 정보를 안전하게 관리했습니다.
// Project.swift
settings: .settings(
    base: [:],
    configurations: [
        .debug(name: "Debug", xcconfig: "Tuist/Config/Config.xcconfig"),
        .release(name: "Release", xcconfig: "Tuist/Config/Config.xcconfig"),
    ]
),
targets: [
    .target(
        name: "SwypApp2nd",
        // ...
        infoPlist: .extendingDefault(with: [
            "KAKAO_APP_KEY": "$(KAKAO_APP_KEY)",
            "DEV_BASE_URL": "$(DEV_BASE_URL)",
            // ...
        ]),
        dependencies: [
            .external(name: "Alamofire"),
            .external(name: "Kingfisher"),
            .external(name: "KakaoSDKCommon"),
            // ... Kakao SDKs
        ],
        // ...
    ),
    // ...
]

2.2. 아키텍처: MVVM (Model-View-ViewModel)

  • 선택 이유: View의 역할(UI 표시 및 사용자 입력)과 Business Logic(데이터 처리, 상태 관리)을 명확히 분리하여 코드의 가독성, 테스트 용이성, 유지보수성을 높이기 위해 MVVM 패턴을 채택했습니다.
  • 구현: 각 View에 대응하는 ViewModel을 만들어 데이터 흐름과 상태 변화를 관리했습니다. SwiftUI와의 조합을 통해 데이터 바인딩을 효과적으로 활용했습니다.

2.3. UI 프레임워크: SwiftUI

  • 선택 이유: iOS 17.0을 타겟으로 하고 MVVM 패턴을 사용하는 점, 그리고 선언적이고 현대적인 UI 개발 방식의 이점을 고려하여 SwiftUI를 주력 UI 프레임워크로 사용했습니다.
  • 장점: 코드의 양을 줄이고, 실시간 프리뷰를 통해 UI 개발 생산성을 크게 향상시킬 수 있었습니다. MVVM 패턴과의 자연스러운 통합도 장점입니다.

2.4. 네트워킹: Alamofire & Backend API

  • Alamofire: 백엔드 서버와의 안정적이고 편리한 HTTP 통신을 위해 검증된 라이브러리인 Alamofire를 사용했습니다. (dependencies: [.external(name: "Alamofire")])
  • Backend API 연동: 자체 백엔드 서버와 RESTful API 통신을 통해 로그인, 친구 목록 조회/추가/수정/삭제, 프로필 관리, 챙김 기록 등 앱의 핵심 기능을 구현했습니다.

2.5. 사용자 인증: Kakao SDK, Sign in with Apple, Token Management

  • Kakao SDK: 핵심 로그인 방식으로 카카오 로그인을 사용하며, 사용자 정보 및 친구 목록(동의 시)을 가져오는 데 활용했습니다. (KakaoSDKCommon, Auth, User, Friend 등 다수 모듈 사용)
  • Sign in with Apple: Apple 사용자를 위한 간편 로그인 옵션을 제공합니다. (entitlements: "Tuist/SignInWithApple.entitlements")
  • Token 기반 인증: 백엔드와의 안전한 통신을 위해 Access Token과 Refresh Token을 관리하고, Keychain에 안전하게 저장하며 자동 로그인 및 토큰 갱신 로직을 구현했습니다.

2.6. 비동기 이미지 처리: Kingfisher & Presigned URL

  • Kingfisher: 서버로부터 받은 친구 프로필 이미지 URL 등을 비동기적으로 다운로드하고 캐싱하여 UI에 효율적으로 표시하기 위해 Kingfisher를 사용했습니다. (dependencies: [.external(name: "Kingfisher")])
  • Presigned URL: 친구 프로필 이미지 업로드/다운로드 시 AWS S3 Presigned URL 방식을 사용하여 서버 부하를 줄이고 효율성을 높였습니다. 

2.7. 로컬 데이터 관리: Core Data

  • 활용: 알림(Notification) 관련 데이터를 로컬에 저장하고 관리하는 데 Core Data를 사용했습니다. (coreDataModels 설정: NotificationContainer.xcdatamodeld) 향후 오프라인 지원 강화 등 로컬 데이터 활용 범위를 넓힐 가능성을 염두에 두었습니다.

2.8. 시스템 연동: Contacts Framework

  • 사용자의 주소록에 접근하여 친구를 가져오는 기능을 구현하기 위해 Contacts Framework를 사용했습니다. 사용자에게 명확한 권한 요청 안내를 제공합니다. 

2.9. UI/UX 커스터마이징

  • Custom Fonts (Pretendard): 앱 전체 디자인의 일관성을 위해 Pretendard 폰트를 적용했습니다.
  • Light Mode 고정: 초기 버전에서는 디자인 리소스 및 개발 집중도를 고려하여 Light 모드만 지원하도록 설정했습니다. 
  • LaunchScreen: 앱 시작 시 브랜드 로고 등을 보여주는 LaunchScreen 스토리보드를 사용합니다. 

3. 주요 기능 구현 하이라이트

기술 스택을 바탕으로 다음과 같은 주요 기능들을 구현했습니다.

  • 로그인 및 온보딩: 카카오/애플 SDK를 이용한 간편 로그인 후, 서비스 이용 약관 동의, 연락처/카카오 친구 가져오기, 연락 주기 설정으로 이어지는 자연스러운 온보딩 플로우를 설계했습니다.
  • 친구 관리: 연락처 및 카카오톡에서 가져온 친구를 '챙길 친구'로 등록하고, 맞춤 연락 주기를 설정합니다. Alamofire를 통해 백엔드 API와 연동하여 친구 정보를 생성, 조회, 수정, 삭제합니다.
  • 홈 화면: '이번 달 챙길 친구'와 '내 사람들' 목록을 보여주며, 친구를 챙겼음을 기록하는 기능을 제공합니다. 
  • 프로필 상세 및 수정: 친구의 상세 정보(연락처, 기념일, 메모 등)를 확인하고 수정하는 기능을 구현했습니다. 

4. 개발 과정에서의 도전과 배움 (Challenges & Learnings)

개발 과정은 순탄하지만은 않았습니다. 몇 가지 기술적 어려움에 직면했고, 이를 해결하며 값진 경험을 얻었습니다.

  • Tuist와 Kakao SDK 리소스 번들: Kakao SDK 중 친구 목록 관련 기능을 사용할 때, SDK 내부 리소스 번들을 Tuist 프로젝트에 올바르게 포함시키는 과정에서 어려움을 겪었습니다. Tuist의 리소스 처리 방식과 Kakao SDK 문서 검토를 통해 해결했습니다.
  • 데이터 동기화 및 상태 관리: 친구 추가/수정 후 홈 화면 등 다른 View에 변경 사항이 즉시 반영되지 않는 문제가 있었습니다. SwiftUI의 @State, @ObservedObject, @EnvironmentObject 등을 활용한 상태 관리 로직을 개선하고, 데이터 흐름을 명확히 하여 해결했습니다.
  • 비동기 처리: 여러 친구의 프로필 이미지를 동시에 업로드하거나, 다수의 API 호출 후 UI를 업데이트하는 과정에서 DispatchGroup 등을 활용하여 비동기 코드를 안정적이고 효율적으로 관리했습니다.
  • Tuist와 Core Data 연동: 초기 설정 시 Core Data 모델 파일(.xcdatamodeld) 경로를 Tuist 프로젝트 설정(Project.swift)에 정확히 명시하여 인식시키는 데 약간의 시행착오가 있었습니다.
  • API 명세 변경 대응: 개발 중 백엔드 API 명세가 변경되는 경우가 있었고, 이에 맞춰 모델과 네트워킹 코드를 신속하게 수정하며 백엔드 팀과의 긴밀한 소통의 중요성을 다시 한번 느꼈습니다.

5. 마무리하며

'near' 앱은 Tuist를 통한 효율적인 프로젝트 관리, MVVM 아키텍처 기반의 구조화된 코드, SwiftUI를 활용한 현대적인 UI 구현, 그리고 Alamofire, Kingfisher, Kakao SDK 등 검증된 라이브러리 및 Core Data, Contacts 등 시스템 프레임워크를 적극적으로 활용하여 개발되었습니다.

개발 과정에서의 도전들은 저희 팀을 더욱 성장시키는 계기가 되었습니다. 앞으로도 사용자분들이 친구들과의 소중한 관계를 'near'를 통해 더 쉽고 의미 있게 가꿔나갈 수 있도록, 꾸준히 기능을 개선하고 안정화해 나갈 예정입니다.

긴 글 읽어주셔서 감사합니다! 'near' 앱 개발 과정이나 사용된 기술에 대해 궁금한 점이 있다면 언제든지 댓글로 남겨주세요.

https://simth999wrld.tistory.com/80

 

우리말 퀴즈 앱)6. Local Notification 적용, API KEY 숨기기.

https://simth999wrld.tistory.com/79 우리말 퀴즈 앱)5. urlSession을 async await으로 바꾸기..https://simth999wrld.tistory.com/78 우리말 퀴즈 앱)4. 위젯, Scheme 사용한 API KEY 숨기기https://simth999wrld.tistory.com/77 우리말 퀴

simth999wrld.tistory.com


간단한 기능들은 일단락 된것같아 앱스토어에 앱을 올려보았습니다.. 
의외로 간단해서 조금 당황했습니다. (딱히 심사할게 없어서 그런지..?)

배포에 대한 자세한 내용은 다른분들이 정성스럽게 정리해 주셔서.. 많이 참고했습니다.. (밑의 링크 확인해주셔요~)


 

일단 앱에 대한 정보 등등을 입력하여서 심사를 넣었습니다.

큰 어려움은 없더라구요.

 

아! 개인정보 처리 방침과, 지원 URL에 대한 노션 페이지를 연결해주었습니다.
회원 관리는 하지 않아 이부분도 큰 어려움은 없었스니다 하하..

 

앱 스크린샷 또한 해외 사이트를 이용해 간단하게 제작 했습니다.

 

반려..

 

 

 

 

 

 

 

대충 메일의 내용은

당신의 앱이 iPad 기기 화면 크기나 해상도에 최적화 되어있지 않습니다.

 

라네요 하하. 저는 iPhone용으로 개발을 했지만

리뷰어들은 무조건 아이패드로 앱 실행 한다고 합니다.

 

 

 

 

 

 

 

그래서 저도 아이패드로 시뮬레이터를 돌려보니 저런 현상이...

이게뭐람...

 

다시 뷰를 고치고 재심사를 하였습니다.

 

심사가 아주 빠르더군요,,

다른분들은 회사의 앱인지 몇십일 정도씩 걸리시던데

 

저는 작아서 빠른건가요? 하하~

 

 

 

 

해결해야할 문제

일단 배포는 했지만! (많이 한것도 없는데 뿌듯함 ㅋㅋ)

혼자 사용을 해보다 크디큰 이슈가 많습니다...

 

1. 가장 큰 기능인 "오늘의 단어"가 하루에 한번 바뀌지 않습니다... 전혀 바뀌지 않음..

2. 푸시 알림 시간을 설정후 앱 껐다키면 시간이 현재시간으로 바뀌는 문제가 있더군요

예를 들어 8:30 분에 알림을 설정 해두었다 앱을 껐다 다시 키면, 알림 시간이 현제 시간으로 바뀌어있습니다. (알림은 8:30에 울림)

 

 

느낀점 이랄까..?

SwiftUI를 배워보면서 다른분들이 항상 이야기 하는 "앱을 만들면서 공부해라~" 라는 말이 정말 무슨말인지

깨닫게 되었네요. 이 앱을 만들면서 여러 가지 기능에 대해 공부하고 문법에 대해 조금더 친숙해진 느낌입니다.

 

혹자가 말하기를 강의 지옥에 빠진다고들 하잖아요. 제가 그 경우였습니다. 

강의만 여러개 들으며 공부를 하고 있다는 자아도취에 빠지는 경우였네요;;

(물론 지금도 결제하고 안들은 강의가 있ㄷ,,)

그래도 저만의 앱을 만들어 배포까지 하는 경험 이 첫경험이 아주 귀중한거 같습니다.

음 기술적으로 성장했는지는 몰라도 개발자로서 한발자국 다가간 느낌입니다.

 

여러 방면에서도 성장했겠죠..?

지금은 모든 코드를 검색을 해가면서 작성해야하는 수준이지만..

언젠간 눈감고도 코드를 작성하는 그날이 오겠죠? (오겠냐)

 

다음 포스팅은 현재 이앱의 문제점을 개선하는 주제가 될것입니다..

 

 

 

참고

https://developer.apple.com/kr/help/app-store-connect/reference/app-information/

 

앱 정보 - 참고 - App Store Connect - 도움말 - Apple Developer

참고 앱 정보 앱 정보는 앱에 추가된 플랫폼 간에 공유되는 속성을 나타냅니다. 계정에 앱을 추가할 때 해당 속성 중 일부를 설정하고, 추후 필요한 경우 앱 정보를 수정하거나 앱 상태에 따라

developer.apple.com

https://developer.apple.com/kr/app-store/review/guidelines/?source=post_page-----f30e54b87dba--------------------------------

 

앱 심사 지침 - Apple Developer

앱 심사 지침에는 사용자 인터페이스 디자인, 기능, 콘텐츠 및 특정 기술 사용 등을 비롯하여 개발과 관련된 다양한 주제에 대한 지침과 예가 나와 있습니다. 이러한 지침은 앱 승인 절차를 준비

developer.apple.com

https://hammerbrother.tistory.com/entry/%EC%95%B1%EC%8A%A4%ED%86%A0%EC%96%B4-%EC%95%B1-%EB%93%B1%EB%A1%9D%ED%95%98%EA%B8%B0-%EC%8A%A4%ED%81%AC%EB%A6%B0%EC%83%B7-%EB%AF%B8%EB%A6%AC%EB%B3%B4%EA%B8%B0-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EA%B0%80%EC%9D%B4%EB%93%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%82%AC%EC%9D%B4%EC%A6%88-%EC%A1%B0%EC%A0%88%ED%95%98%EA%B8%B0-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%A1%B0%EC%A0%88-%EC%82%AC%EC%9D%B4%ED%8A%B8-%EB%AA%A9%EC%97%85%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%82%AC%EC%9D%B4%ED%8A%B8-%EC%86%8C%EA%B0%9C

 

앱스토어 앱 등록하기, 스크린샷 미리보기 이미지 가이드! 이미지 사이즈 조절하기, 이미지 조절

열심히 기획/디자인하고 개발까지 하여 아주아주 소중한 우리의 앱이 탄생하기 직전이다. 앱을 출시하려면 앱스토어에 등록하고, 구글 플레이 스토어에 등록해야 한다. 앱을 등록하는 과정에서

hammerbrother.tistory.com

https://devsrkim.tistory.com/entry/%EC%95%B1-%EC%8A%A4%ED%86%A0%EC%96%B4-%EC%95%B1-%EB%B0%B0%ED%8F%AC-%ED%95%98%EA%B8%B0

 

앱 스토어 앱 배포 하기

1. AppStore Connect 접속 (appstoreconnect.apple.com/) - 계정이 없다면, 계정 생성 필수. 2. 로그인 후, 상단 메뉴 '앱' > (+) 버튼 클릭 3. 신규 클릭 > 기본 정보 입력 > 생성 - 플랫폼 : iOS - 이름 : 앱 이름 - 기본

devsrkim.tistory.com

 

https://simth999wrld.tistory.com/79

 

우리말 퀴즈 앱)5. urlSession을 async await으로 바꾸기..

https://simth999wrld.tistory.com/78 우리말 퀴즈 앱)4. 위젯, Scheme 사용한 API KEY 숨기기https://simth999wrld.tistory.com/77 우리말 퀴즈 앱)3.Alamofier, 네이버 검색 API 사용 https://simth999wrld.tistory.com/76 우리말 퀴즈

simth999wrld.tistory.com

 

흠 이번 포스팅을 하기전에 앱의 디자인과 앱 아이콘을 설정해서 바뀐모습 먼저 보여드리겠습니다 ^_^7

 

 

 

보시는 분들은 이정도로 만족 못하시겠지만..

보시는것처럼 제가 감각이 없나봐요...하하

 

일단 모든 뷰의 NavigationTitle을 추가했습니다.

 

조금이나마 뷰를 채웠으면 하는 마음에 크게 했습니다..

 

 

 

 

 

세번째의 탭이 Notification에서 Setting으로 바뀌었습니다.

조금더 범용적인 단어가 좋을것 같아 바꿨습니다.

 

네, 맞아요 푸시 알림 하나 해서 포스팅 하러 왔습니다...

 

SwiftUI를 조금 배우고 바로 시작한 프로젝트라 부족한점이 많지만

조금씩 바꾸고 있습니다 ㅎㅎ 많관부 

 

 

 

 


API KEY 숨기기

 

일단 이전 포스팅에서 API키를 Product/Scheme/Edit Scheme/Run에 적용을 했는데,

이건 빌드 환경을 분리할때 사용하는것 같아서, 이후에 제가 앱을 완성한후에 테스트 관련 포스팅에 다시 작성을 하겠습니다.

 

API키를 왜 숨기냐.. 

바로 github에 올리게 된다면 다른 사람이 API Key를 스리슬쩍해서 대신 사용할수도 있고, 유료라면 엄청난 가격 폭탄을 맞을수 있겠죠..

저또한 우리말샘API키를 깃에 올라가 다시 발급 받았더랬죠..

 

따라서 .xcconfig를 사용해서 API키들을 관리하겠습니다.

 

 

cmd + N으로 신규 파일을 생성하고 Configuration Settings File을 추가해줍니다.

 

이 파일 안에 사용하는 API키등을 넣고 문자열을 관리할 수 있게 됩니다.

 

 

 

파일 생성 후에

 

 

 

사용하려는 API키의 이름을 적고 API키를 넣으시면 됩니다. (""사용X)

 

 

이렇게 넣어준 후

 

 

 

Project의 Info/Configuration에 Release와 Debug에

추가한 Config를 설정해주시면 됩니다.

그러면 Config.xcconfig파일을 사용할수 있습니다.

 

 

이후 .xcconfig의 변수를 사용 설정을 해줘야겠죠?

Targets/info에 +버튼을 눌러 변수 이름과 value엔 $(변수)로 넣게 된다면 info.plist파일 생성과 함께 같이 적용이 됩니다.

info.plist

자 이러면 사용 준비는 완료가 되었는데요.

네트워킹을 하는 파일에서 API키의 변수를 사용하려면

let naverClientID = Bundle.main.object(forInfoDictionaryKey: "NAVER_CLIENT_ID") as? String

의 형식으로 사용하면 됩니다.

저는 info.plist의 NAVER_CLIENT_ID의 변수에 연결된 api키가 naverClientID에 할당이 됩니다.

 

이렇게 변수를 사용할 수있는데요, 아직 중요한 작업을 하나 안했습니다요~

이렇게 git에 push를 하게 된다면 우리가 만든 .xcconfig파일이 올라가서 무용지물이 됩니다.

따라서 .gitignore에 *.xcconfig 를 넣어 git버전관리에서 제외 시켜줍니다.

 

이렇게 api키를 숨겨보았는데요.

저는 git에 이미 올라간 흔적이 있어,, 재발급 받았습니다.. 여러분도 올리기 전에 .gitignore에 추가한후에 올리시길 바래요~

 


Local Notification 적용

진행중인 프로젝트는 오늘의 단어를 Push Notification을 사용해서 사용자가 설정한 시간에 알림이 오도록 하는 기능이 있습니다!

그전에 앱의 상태에 대해 알아보았는데요.

 

크게 3가지의 상태가 있습니다. 1. 앱이 실행중, 2.백그라운드, 3.앱이 꺼졌을때.

 

제가 졸업작품 프로젝트를 진행했을때엔, 서버에서 Push알림을 생성해 전송을 하여 APNs Apple Push Notification Service를 사용했었는데요, 지금은 간단한 서버리스 앱이라.. 하하 앱에서 알림을 주어야 겠죠??

 

 1. 앱이 실행중, 2.백그라운드, 3.앱이 꺼졌을때. 상태에서 Notificaion을 받아야 합니다.

따라서 Local Notification을 구현했습니다.

 

Local Notification

Local Notification은 앱이 백그라운드에 있거나 사용자가 앱을 사용하지 않을 때에 알림을 보내는 기능입니다.

 

저의 경우엔 알림을 끄고 키고, 시간대 설정을 하여 알림이 오게 되는 기능입니다.

 

로컬 알림을 사용하기 위해 UNUserNotificationCenter를 통해 알림 권한을 먼저 요청 해야합니다.

 

이를 위해 UserNotifications를 import하고 인스턴스를 가져옵니다.

import UserNotifications

let notificationCenter = UNUserNotificationCenter.current()

 

 

저장된 단어와 뜻을 불러와 상수에 할당하고, 알림on/off확인 변수, 알림 설정 시간 변수를 선언합니다.

앱에서 알림on/off를 확인할 변수 또한 선언합니다.

@Published var isAlertOccurred: Bool = false
@Published var notificationTime: Date = Date() {
    didSet {
        removeAllNotifications()
        addNotification(with: notificationTime)
    }
}
@Published var isToggleOn: Bool = UserDefaults.standard.bool(forKey: "hasUserAgreedNoti") {
    didSet {
        if isToggleOn {
            UserDefaults.standard.set(true, forKey: "hasUserAgreedNoti")
            requestNotiAuthorization()
        } else {
            UserDefaults.standard.set(false, forKey: "hasUserAgreedNoti")
            removeAllNotifications()
        }
    }
}

isToggleOn은 didSet을 활용하여 UserDefaults에 저장하고 가져옵니다.

isToggleOn이 True일때는 true를 저장하고, requestNotiAuthorization() 메소드를 호출하게 됩니다.

 

requestNotiAuthorization메서드는 Setting에 앱의 알림 설정을 확인하는 메소드 입니다.

func requestNotiAuthorization() {
        //noti 설정 가져오기
        //상태에 따라 다른 액션 수행
        notificationCenter.getNotificationSettings { settings in
            //승인이 되어있지 않은 경우 request
            if settings.authorizationStatus != .authorized {
                self.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
                    if let error = error {
                        print("notificationCenter Error: \(error.localizedDescription)")
                    }
                    
                    //노티피케이션 최초 승인
                    if granted {
                        self.addNotification(with: self.notificationTime)
                    } else {
                        //노티피케이션 최초 거부
                        DispatchQueue.main.async {
                            self.isToggleOn = false
                        }
                    }
                    
                }
            }
            
            //Notification이 거부되어 있는경우 alert
            if settings.authorizationStatus == .denied {
                DispatchQueue.main.async {
                    self.isAlertOccurred = true
                    
                }
            }
        }
    }

알림의 승인 거부에 따라 status로 관리를 하게 됩니다.

 

 

Toggle이 꺼져 있다면 removeAllNotificaions() 메소드를 호출하여 notificationCenter에 설정되어있는 알림들을 삭제합니다. 

func removeAllNotifications() {
    notificationCenter.removeAllDeliveredNotifications()
    notificationCenter.removeAllPendingNotificationRequests()
}

 

 

알림 설정을 승인했을때 즉 알림이 켜져 있고, Toggle이 On상태 이라면

addNotifiCation(with time: Date)의 메소드를 호출하게 됩니다!

이 메소드는 사용자가 설정한 시간대로 알림을 추가하게 되는 메소드 입니다.

//time에 반복되는 노티피케이션 추가
func addNotification(with time: Date) {
    //이 객체를 사용하여 알림의 제목과 메시지, 재생할 사운드 또는 앱의 배지에 할당할 값을 지정
    let content = UNMutableNotificationContent()

    if let todayWord = storedWord, let todayDescription = storedDefinition {
        content.title = "오늘의 단어: \(String(describing: todayWord))"
        content.subtitle = "뜻: \(String(describing: todayDescription))"
        content.sound = UNNotificationSound.default

        let dateComponent = Calendar.current.dateComponents([.hour, .minute], from: time)
        let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponent, repeats: true)
        let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)

        notificationCenter.add(request) { (error) in
            if let error = error {
                print("Error adding notification: \(error.localizedDescription)")
            }
        }
    }
}

content의 title, subtitle, sound를 추가했습니다.

저는 위젯을 설정할때 단어와 뜻을 UserDefaults에 저장을 해두어서 가져와 사용했습니다.

상세 설명을 하면

content에 알림의 제목, 부제목, 소리를 설정

trigger = dateComponent 시간과 분을 기준으로 알림 트리거를 생성합니다.

request = 알림 요청을 생성하고,

notificationCenter.add(request)로 알림 센터에 추가하게 됩니다.

 

전체 코드

import Foundation
import UserNotifications
import UIKit

class NotificationManager: ObservableObject {
    
    let notificationCenter = UNUserNotificationCenter.current()
    let storedWord = UserDefaults.shared.string(forKey: "TodayWord")
    let storedDefinition = UserDefaults.shared.string(forKey: "TodayWordDefinition")
    
    @Published var isAlertOccurred: Bool = false
    @Published var notificationTime: Date = Date() {
        didSet {
            removeAllNotifications()
            addNotification(with: notificationTime)
        }
    }
    @Published var isToggleOn: Bool = UserDefaults.standard.bool(forKey: "hasUserAgreedNoti") {
        didSet {
            if isToggleOn {
                UserDefaults.standard.set(true, forKey: "hasUserAgreedNoti")
                requestNotiAuthorization()
            } else {
                UserDefaults.standard.set(false, forKey: "hasUserAgreedNoti")
                removeAllNotifications()
            }
        }
    }
    
    func requestNotiAuthorization() {
        //noti 설정 가져오기
        //상태에 따라 다른 액션 수행
        notificationCenter.getNotificationSettings { settings in
            //승인이 되어있지 않은 경우 request
            if settings.authorizationStatus != .authorized {
                self.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
                    if let error = error {
                        print("notificationCenter Error: \(error.localizedDescription)")
                    }
                    
                    //노티피케이션 최초 승인
                    if granted {
                        self.addNotification(with: self.notificationTime)
                    } else {
                        //노티피케이션 최초 거부
                        DispatchQueue.main.async {
                            self.isToggleOn = false
                        }
                    }
                    
                }
            }
            
            //Notification이 거부되어 있는경우 alert
            if settings.authorizationStatus == .denied {
                DispatchQueue.main.async {
                    self.isAlertOccurred = true
                }
            }
        }
    }
    
    func removeAllNotifications() {
        notificationCenter.removeAllDeliveredNotifications()
        notificationCenter.removeAllPendingNotificationRequests()
    }
    
    //time에 반복되는 노티피케이션 추가
    func addNotification(with time: Date) {
        //이 객체를 사용하여 알림의 제목과 메시지, 재생할 사운드 또는 앱의 배지에 할당할 값을 지정
        let content = UNMutableNotificationContent()
        
        if let todayWord = storedWord, let todayDescription = storedDefinition {
            content.title = "오늘의 단어: \(String(describing: todayWord))"
            content.subtitle = "뜻: \(String(describing: todayDescription))"
            content.sound = UNNotificationSound.default
            
            let dateComponent = Calendar.current.dateComponents([.hour, .minute], from: time)
            let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponent, repeats: true)
            let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
            
            notificationCenter.add(request) { (error) in
                if let error = error {
                    print("Error adding notification: \(error.localizedDescription)")
                }
            }
        }
    }
    
    //Setting으로 이동하는 메서드
    func openSettings() {
       if let bundle = Bundle.main.bundleIdentifier,
          let settings = URL(string: UIApplication.openSettingsURLString + bundle) {
            if UIApplication.shared.canOpenURL(settings) {
               UIApplication.shared.open(settings)
            }
        }
    }
        
}

 

전체 코드에 설명을 하지 않았던 openSettings()메소드는 앱에 알림 요청 Alert가 뜨게 되어서 Setting화면으로 연결해 주는 메소드입니다.

 

 

이후 위의 코드(뷰모델 코드) 를 뷰에 적용을 해야겠죠?

 

import SwiftUI

struct SettingView: View {
    
    @EnvironmentObject var notificationManager: NotificationManager
    var body: some View {
        NavigationStack {
            
            VStack(alignment: .leading, spacing: 10){
                Form{
                    //알림 설정 ex) 푸시알림, 효과음
                    Section(header: Text("알림 설정").font(.caption)) {
                        //NotiView
                        NotiView()
                        
                    }
                }
            }
        }
    }
}

 

Toggle버튼이 있는 푸시알림의 Section을 NotiView()로 빼서 작성하였습니다.

//MARK: - NotiView
struct NotiView: View {
    
    @EnvironmentObject var notificationManager: NotificationManager
    @State private var showDatePicker = false
    
    var body: some View {
        VStack(alignment: .leading){
            
            HStack {
                Text("푸시 알림")
                if notificationManager.isToggleOn {
                    DatePicker("", selection: $notificationManager.notificationTime, displayedComponents: .hourAndMinute)
                        .datePickerStyle(.graphical)
                        .onTapGesture {
                            showDatePicker.toggle()
                        }
                }
                Toggle("", isOn: $notificationManager.isToggleOn)
                    .onChange(of: notificationManager.isToggleOn) { oldValue, newValue in
                        if newValue {
                            showDatePicker = true
                        } else {
                            showDatePicker = false
                        }
                    }
            } //HStack
            .alert(isPresented: $notificationManager.isAlertOccurred) {
                //알림 설정이 꺼져있을 경우 설정창으로 이동
                Alert(title:Text("Notification Alert"), message: Text("알림 설정이 꺼져있습니다. 설정에서 알림을 켜주세요."), primaryButton: .cancel(Text("이동"), action: {
                    notificationManager.openSettings()
                }), secondaryButton: .destructive(Text("Cancel")))
            }
            .onTapGesture {
                if notificationManager.isToggleOn {
                    showDatePicker.toggle()
                }
            }
        } //VStack
    }
}

 

DatePicker로 알림 시간을 설정하고 Toggle버튼으로 알림을 끄고 킬수 있도록 하였습니다.

 

.alert에 알림이 꺼져있을경우 설정창으로 이동 할 수 있도록 하였습니다.

여기서 알림이 꺼져있고  "이동" 버튼을 누르면 openSettings()의 메소드가 호출되어 Setting으로 이동하게 됩니다.

 

 

 

 

 

 

 

 

 

 

마지막으로 Toggle을 On하고 시간대를 설정한다면?

 

4

 

 

 

이렇게 알림이 오게됩니다 하하...

 

 

 

 

 

 

 

아직 알림이 왔다 안왔다 하는데,, 그부분은 테스트 관련해서 더욱 학습을 한후에 해야할것같습니다 ㅜㅜ

그래도 알림은 오잖아요 한잔해~

 

 


 

현재 Combine에 대해 공부를 하고있는데 정확한 개념이 머리에 들어오지 않는거 있죠...

항상 느끼는 저의 문제점이지만, 왜? 에 중점을 맞추고 이해해 나가야 하지만,, 

대학교때 배우던 습관이 배어있어서 그런지 그냥 그런가보다.. 하고 머리에 집어넣게 되는것 같습니다.. (절때 대학탓 아님..교수탓아님.)

 

마지막으로 이후에 이 프로젝트에서 해야할것을 나열해보고 글 끝마치겠습니다~

 

1. 알림에 글 잘리는 현상

2. 퀴즈에 사진이 답을 스포하는 현상

3. 위젯 이쁘게...

4. 약관 작성

5. 테스트

 

를 하게 된다면 배포까지 할수있을것같습니다 하하 (저의생각)


도움주신분들 감사드립니다,,

https://velog.io/@j_aion/SwiftUI-Local-Notifications

 

[SwiftUI] Local Notifications

How to schedule local Push Notifications in SwiftUI | Continued Learning 실제 노티는 서버에서 푸시하는 게 일반적로컬 특정 조건이 만족된다면 서버를 사용하지 않아도 푸시 가능좋은 UI의 기본이 될 수 있는 방

velog.io

https://velog.io/@maddie/iOS-%EB%A1%9C%EC%BB%AC%EB%A1%9C-%EC%95%8C%EB%A6%BC-Notification-%EB%A7%8C%EB%93%A4%EA%B8%B0

 

[iOS][SwiftUI] 로컬로 알림 Notification 만들기

😤기능: alert 경고 표시, sound 소리 재생, badge app icon 아이콘 배지 표시사용자가 원하는 중요한 정보를 전달하기 위해 사용!앱이 실행되지 않았거나, 백그라운드: 시스템이 대신 사용자와 상호작

velog.io

 

https://2unbini.github.io/%F0%9F%93%82%20all/swift/swiftUI-Local-Notification/

 

SwiftUI, Local Notification 구현하기

back/foreground 둘 다 작동하는 로컬 푸시 알림 설정하기

2unbini.github.io

짱짱맨..bb

 

GitHub

https://github.com/jjwon2149/WordQuizDaily

 

GitHub - jjwon2149/WordQuizDaily: 단어 퀴즈 앱

단어 퀴즈 앱. Contribute to jjwon2149/WordQuizDaily development by creating an account on GitHub.

github.com

 

 

https://simth999wrld.tistory.com/78

 

우리말 퀴즈 앱)4. 위젯, Scheme 사용한 API KEY 숨기기

https://simth999wrld.tistory.com/77 우리말 퀴즈 앱)3.Alamofier, 네이버 검색 API 사용 https://simth999wrld.tistory.com/76 우리말 퀴즈 앱)2.퀴즈뷰 구성중... https://simth999wrld.tistory.com/75 우리말 퀴즈 앱)1.우리말샘 api

simth999wrld.tistory.com

 

오랜만에 보니 뭐라고 코드를 적었는지 하나도 이해가 가지 않았습니다.. 이는 제가 코드를 잘못짠 탓이겠지요...

 


미루고 미루던 urlSession부분에 async await을 적용 하였습니다.

func searchWord(_ searchWord: String, completion: @escaping (WordData?) -> Void) {
    let urlString = "https://opendict.korean.go.kr/api/search?certkey_no=6282&key=\(myApiKey)&target_type=search&req_type=json&part=word&q=\(searchWord)&sort=dict&start=1&num=10"
    if let url = URL(string: urlString) {
        let session = URLSession(configuration: .default)
        let task = session.dataTask(with: url) { data, response, error in
            if error == nil {
                if let safeData = data {
                    do {
                        let decodedData = try JSONDecoder().decode(WordData.self, from: safeData)
                        DispatchQueue.main.async {
                            self.wordData = decodedData
                            completion(decodedData) // 완료 핸들러 호출
                        }
                    } catch {
                        print(error)
                        completion(nil)
                    }
                }
            } else {
                print(error)
                completion(nil)
            }
        }
        task.resume()
    }
}

 

보이시나요... 에너지파를 날리는 이전의 코드..

최신 강의를 보지않고 예전의 강의를 보아 저에겐 익숙하지만,, 보기 껄끄럽습니다.

저또한 저의 코드를 보면서 시간을 조금 보냈군요..ㅋㅋ

 

func asyncSearchWord(_ searchWord: String) async throws -> WordData? {
    let urlString = "https://opendict.korean.go.kr/api/search?certkey_no=6282&key=\(myApiKey)&target_type=search&req_type=json&part=word&q=\(searchWord)&sort=dict&start=1&num=10"
    guard let url = URL(string: urlString) else { return nil }

    do {
        let (data, response) = try await URLSession.shared.data(from: url)
        let decodedData = try JSONDecoder().decode(WordData.self, from: data)
        DispatchQueue.main.async {
            self.wordData = decodedData
        }
        return decodedData
    } catch {
        print(error)
        throw error
    }
}

async await을 사용해 바꾼 네트워크 코드입니다.

한눈에 봐도 정말 속이 시원해지는 코드입니다..

 

하지만 문제는 이후 입니다.

 

viewModel에서 코드를 고치다 보니 네트워크 요청을 중복으로 사용하고 있었습니다!

fetchData()와 generateChoice() 함수에서 중복으로 호출을 하고 있었습니다...

 

따라서 fetchData()에서는 네트워크 요청을 하는 함수인 setupNewQuiz()를 호출하여 data를 가져오는 함수로 작성을 하였고,

setupNewQuiz() 에선 비동기로 네트워킹을 합니다.

func fetchData() {
    Task {
        await setupNewQuiz()
    }
}

//퀴즈 셋업
func setupNewQuiz() async {
    isLoading = true
    correctWord = hardKoreanWords.hardWords.randomElement() ?? ""
    choiceWord = await generateChoices()

    do {
        let wordData = try await wordNetwork.asyncSearchWord(correctWord)
        await handleWordData(word: correctWord, wordData: wordData)
    } catch {
        handleNetworkError(error)
    }

    isLoading = false
}

 

그리고 엑티비티 인디케이터를 구분할수 있는 isLoading을 toggle하는 부분을  모두 메인 쓰레드에서 작동 하도록 바꾸었습니다.

DispatchQueue.main.async {
    self.isLoading = false
}


setupNewQuiz()에서 볼수 있는 handleNetworkError의 함수는 에러 핸들링을 위해 작성 하였습니다.

네트워크 요청이 실패 했을떄 호출되는 함수 입니다.

//MARK: - Error Handling
func handleNetworkError(_ error: Error) {
    DispatchQueue.main.async {
        self.errorMessage = error.localizedDescription
        self.isLoading = false
    }
}

 

에러발생

빌드를 진행하니

@Published 로 선언한 변수에서
Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

라는 에러가 발생하였습니다.

 

이는 백그라운드 쓰레드에서 UI를 업데이트를 하려고해 나타나는 오류라고 합니다!

-> @Published 속성을 백그라운드 쓰레드에서 변경하려고 시도하여 발생한다고 합니다.

이 문제를 해결하려면 변경되는 부분을 메인 쓰레드 에서 실행을 해야 합니다.

@MainActor
func setupNewQuiz() async {
    isLoading = true
    correctWord = hardKoreanWords.hardWords.randomElement() ?? ""
    choiceWord = await generateChoices()

    do {
        let wordData = try await wordNetwork.asyncSearchWord(correctWord)
        await handleWordData(word: correctWord, wordData: wordData)
    } catch {
        handleNetworkError(error)
    }

    isLoading = false
}

setupNewQuiz 메서드를 MainActor를 사용하여 메인 스레드에서 실행할 수 있도록 명시적으로 지정하였습니다.

 

 

 


저는 비동기 처리에 대한 개념을 더욱 확실하게 잡아야 합니다.. 네트워킹을 비동기로 바꾼후 적용하는 코드에서

많은 오류를 보았더랬죠..  언제쯤 수월하게 코드를 작성할 수 있게 될까요..




참조


https://developer.apple.com/videos/play/wwdc2021/10132/

 

Meet async/await in Swift - WWDC21 - Videos - Apple Developer

Swift now supports asynchronous functions — a pattern commonly known as async/await. Discover how the new syntax can make your code...

developer.apple.com

https://dev-mandos.tistory.com/28

 

[WWDC21] Use async/await with URLSession을 적용해보자..!

해당 글은 [WWDC21] Use async/await with URLSession 보고 작성했습니다. Swift Concurrency 에 대한 얘기가 몇몇 나왔었다. Swift Concurrency에 대해 간략하게 설명해보자면, 코드를 선형적이고 간결하게 만들고, Nat

dev-mandos.tistory.com


GitHub

https://github.com/jjwon2149/WordQuizDaily

 

GitHub - jjwon2149/WordQuizDaily: 단어 퀴즈 앱

단어 퀴즈 앱. Contribute to jjwon2149/WordQuizDaily development by creating an account on GitHub.

github.com

 

https://simth999wrld.tistory.com/76

 

우리말 퀴즈 앱)2.퀴즈뷰 구성중...

https://simth999wrld.tistory.com/75 우리말 퀴즈 앱)1.우리말샘 api https://simth999wrld.tistory.com/74 1차시. OT 간단하게 스터디 일정과 인사후 개인프로젝트 소개를 하였습니다, ^_^/ 간단하게 아이디어를 정리해

simth999wrld.tistory.com

이번에 구성에 대해 조금더 업그레이드를 해보았는데요,,하하

 

네이버 검색 API를 사용해서 정답 단어의 이미지를 가져오는것 입니다..ㅎ

 

 

 

 

디렉토리 구조를 조금 바꾸었습니다.. (이렇게 하는거 맞나.,.?)

 

조금더 고민한 후에 조금더 보기 편하게 만들어 보겠습니다^_^

 

 

 

 

 

 

 

 

네이버 이미지검색 API

 

일단 네이버 검색 API를 사용하기 위해 네이버 디벨로퍼 회원가입, 키 발급 등을 합니다.

 

https://developers.naver.com/docs/serviceapi/search/image/image.md

 

검색 > 이미지 - Search API

검색 > 이미지 이미지 검색 개요 개요 검색 API와 이미지 검색 개요 검색 API는 네이버 검색 결과를 뉴스, 백과사전, 블로그, 쇼핑, 웹 문서, 전문정보, 지식iN, 책, 카페글 등 분야별로 볼 수 있는 API

developers.naver.com

 

 

 

 

묵념을 검색을 하게된다면 이렇게 최소 10개의 이미지를 가져올 수 있게 됩니다.

 

저는 가장 맨 위의 이미지만을 사용하겠습니다.

 

이 JSON형식에 따라 Swift파일을 만들어 줍니다.

 

 

 

 

 

- NaverImageData.swift -

import Foundation

// MARK: - NaverImageData
struct NaverImageData: Codable {
    let lastBuildDate: String
    let total, start, display: Int
    let items: [NaverItem]
}

// MARK: - NaverItem
struct NaverItem: Codable {
    let title: String
    let link: String //이미지의 URL
    let thumbnail: String
    let sizeheight, sizewidth: String
}

 

이렇게 만들어 주는데 물론 손으로 만드면 좋겠지마는~ 

 

https://app.quicktype.io/

 

Instantly parse JSON in any language | quicktype

 

app.quicktype.io

 

이 사이트에서 여러 타입의 데이터들을 코드로 만들어 줍니다. 편리하죠..?

 

 

클라이언트 아이디와 시크릿 값을 저장하기 위한 파일을 만들어 줍니다.

 

- NaverStorage.swift -

//TODO: - gitignore에 NaverStorage.swift 추가하기..
import Foundation

struct NaverStorage {
    let naverClientID: String = "Your naverClientID"
    let naverClientSecret: String = "Your naverClientSecret"
}

 

 

앗 그전에 까먹은 것이 있네요..

Alamofire를 사용해 HTTP통신을 하여 네이버 API를 사용해야하니 Alamofire또한 Cocopod를 통해 설치해 줍니다.

(킹피셔로 이미지를 보여줄것이니 같이 다운 받아요 ㅎㅎ)

pod 'Alamofire'
pod 'Kingfisher', '~> 7.0'

 

 

pod install을 하였다면,, 이제 HTTP통신을 해야 하겠죠?

 

HTTP 통신에 사용되는 메소드를 편하게 사용하기 위해 enum에 저장하여 사용합니다.

 

- HTTPMethod.swift - 

enum HTTPMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case head = "HEAD"
    case delete = "DELETE"
    case patch = "PATCH"
    case trace = "TRACE"
    case options = "OPTIONS"
    case connect = "CONNECT"
}

 

 

Alamofire를 사용해 네트워크 통신 부분을 작성 해보겠습니다.

 

- NaverNetwork.swift -

import Foundation
import Alamofire


protocol NaverNetworkDelegate: AnyObject {
    func imageDataUpdated(_ imageData: NaverImageData?)
}

class NaverNetwork: ObservableObject {
    static let shared = NaverNetwork()
    private init() {}
    
    @Published var imageData: NaverImageData?
    
    weak var delegate: NaverNetworkDelegate?
    
    func requestSearchImage(query: String, completion: @escaping () -> Void) {
        let baseURL = "https://openapi.naver.com/v1/search/image"
        
        let headers: HTTPHeaders = [
            "X-Naver-Client-Id": NaverStorage().naverClientID,
            "X-Naver-Client-Secret": NaverStorage().naverClientSecret,
        ]
        
        let parameters: Parameters = [
            "query": query,
            "display": 50
        ]
        
        AF.request(baseURL,
                   method: .get,
                   parameters: parameters,
                   encoding: URLEncoding.default,
                   headers: headers)
        .validate(statusCode: 200...500)
        .responseDecodable(of: NaverImageData.self) { response in
            switch response.result {
            case .success(let data):
                guard let statusCode = response.response?.statusCode else { return }
                if statusCode == 200 {
                    DispatchQueue.main.async {
                        self.imageData = data
                        self.delegate?.imageDataUpdated(data)
                        completion()
                    }
                }
                print("\(#file) > \(#function) :: SUCCESS")
            case .failure(let error):
                print("\(#file) > \(#function) :: FAILURE : \(error)")
            }
        }
    }
}

 

(NaverNetworkDelegate이 뒤에 이미지를 QuizView에서 보여져야 하지만 이미지가 보이지 않았어서 추가하였습니다.. 이부분은 추가로 글쓸 예정!)

 

QuizView에서 보여질 이미지를 위해 QuizViewModel에서 메소드들을 작성해 줍니다.

//MARK: - KoreanWordSearchAPI

// 단어 데이터 처리 메서드
func handleWordData(word: String, wordData: WordData?) {
    if let wordData = wordData {
        wordDataDictionary[word] = wordData
        // 정답 단어의 설명을 가져오기
        if word == correctWord {
            fetchCorrectWordDefinition()
            fetchImageForWord(correctWord)
        }
    } else {
        print("\(word) 단어 데이터를 가져오지 못함 ")
        isLoading = false // 데이터를 가져오지 못한 경우 isLoading을 false로 설정하여 프로그레스 뷰를 숨김
    }

    isLoading = false
}

// 정답 단어의 설명 가져오기
func fetchCorrectWordDefinition() {
    isLoading = true

    guard let wordData = wordDataDictionary[correctWord] else {
        correctWordDefinition = "설명을 가져올 수 없습니다."
        isLoading = false // 작업 완료
        return
    }

    if let firstSense = wordData.channel.item.first?.sense.first {
        correctWordDefinition = firstSense.definition
        print(correctWordDefinition)
    } else {
        correctWordDefinition = "설명을 찾을 수 없습니다."
    }

    isLoading = false // 작업 완료
}

//MARK: - NaverSearchAPI

func fetchImageForWord(_ word: String) {
    isLoading = true

    naverNetwork.requestSearchImage(query: word){ [weak self] in
        // 이미지 데이터 로드 완료 시에만 isLoading을 false로 설정
        self?.isLoading = false
    }
}

//MARK: - NaverNetworkDelegate
func imageDataUpdated(_ imageData: NaverImageData?) {
    DispatchQueue.main.async {
        self.imageData = imageData
    }
}
}

 

 

 

 

Kingfisher

킹피셔를 사용하여 이미지를 띄워 줍니다.

//MARK: - 문제의 이미지
VStack{
    if quizViewModel.isLoading {
        ProgressView("Loading...")
            .frame(width: 200, height: 200)
    } else {
        if let imageData = quizViewModel.imageData {
            if let firstImage = imageData.items.first {
                KFImage(URL(string: firstImage.link))
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 200, height: 200)
            }
        }
    }
}
.padding()

 

 

 

디자인은 잼병이라,, 다른앱을 참고하며 바꿔보도록 하겠습니다.

 

 

 

 

이렇게 이미지를 가져올수 있게 되었습니다!!

 

 

문제 발생..

문제라 함은 2개가 발생 하였는데요...

 

문제 1.

//TODO: - gitignore에 NaverStorage.swift 추가하기..
import Foundation

struct NaverStorage {
    let naverClientID: String = "Your naverClientID"
    let naverClientSecret: String = "Your naverClientSecret"
}

 

이부분은 큰 문제는 아니지만, 조금더 보안을 신경 써준다면 키체인을 사용하면 좋아 보입니다!!!

 

 

문제 2.

 

 

 

 

 

 

 

 

바로 이미지에서 정답을 알수있는 이미지가 나온다는 사실...

관련 이미지를 싹 깃허브에 올려서 가져오는 방법으로 해야 이런 문제가 안날것 같지만..

시간이 많이 걸릴것 같군요 하하..ㅎ 

 

(아이디어 있다면 알려주세요..)

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

긴글 읽어주셔서 감사합니다 ^_^🙏

 

 

 

 

 

 

 

 

https://developers.naver.com/docs/serviceapi/search/image/image.md#%EC%9D%B4%EB%AF%B8%EC%A7%80-%EA%B2%80%EC%83%89-%EA%B2%B0%EA%B3%BC-%EC%A1%B0%ED%9A%8C

 

검색 > 이미지 - Search API

검색 > 이미지 이미지 검색 개요 개요 검색 API와 이미지 검색 개요 검색 API는 네이버 검색 결과를 뉴스, 백과사전, 블로그, 쇼핑, 웹 문서, 전문정보, 지식iN, 책, 카페글 등 분야별로 볼 수 있는 API

developers.naver.com

https://github.com/Alamofire/Alamofire

 

GitHub - Alamofire/Alamofire: Elegant HTTP Networking in Swift

Elegant HTTP Networking in Swift. Contribute to Alamofire/Alamofire development by creating an account on GitHub.

github.com

https://velog.io/@dbqls200/iOS-%EB%84%A4%EC%9D%B4%EB%B2%84-%EA%B2%80%EC%83%89%EB%8F%84%EC%84%9C-API-%EC%82%AC%EC%9A%A9%EA%B8%B0

 

[iOS] 네이버 검색(도서) API 사용기

네이버 Open API를 사용해서 책 검색 해보기

velog.io

 

https://simth999wrld.tistory.com/75

 

우리말 퀴즈 앱)1.우리말샘 api

https://simth999wrld.tistory.com/74 1차시. OT 간단하게 스터디 일정과 인사후 개인프로젝트 소개를 하였습니다, ^_^/ 간단하게 아이디어를 정리해보았습니다.. 진행과정을 간간히 올려보도록 노력! 프로

simth999wrld.tistory.com

 

네~! 저번엔 간단히 api를 호출해서 단어의 뜻과 설명등을 가져오는 코드를 작성했습니다,,

현재까지 얼마나 했나,,, 보고아닌 보고를 하기위해 작성하였습니다 ^_^/

 

 

아직 구상을 하는 중입니다만,, 

 

아마 만들어가면서 계속 구상을 할것같아요 하하

 

간단히 4개의 보기를 주고, 문제의 뜻에 대한 단어를 찾는

간단한 뷰를 만들어보았습니다.

 

나름대로 view와 viewModel을 만들어 보았지만,, view의 코드가 깨끗하지 않은점,,

양해 부탁드립니다 ㅎ

 

 

 

 

 

 

 

저번 글에서 URLSession을 통해 api통신을 하는 코드중 수정을 조금 하였습니다.

func searchWord(_ searchWord: String, completion: @escaping (WordData?) -> Void) {
    let urlString = "https://opendict.korean.go.kr/api/search?certkey_no=6282&key=\(myApiKey)&target_type=search&req_type=json&part=word&q=\(searchWord)&sort=dict&start=1&num=10"
    if let url = URL(string: urlString) {
        let session = URLSession(configuration: .default)
        let task = session.dataTask(with: url) { data, response, error in
            if error == nil {
                if let safeData = data {
                    do {
                        let decodedData = try JSONDecoder().decode(WordData.self, from: safeData)
                        DispatchQueue.main.async {
                            self.wordData = decodedData
                            completion(decodedData) // 완료 핸들러 호출
                        }
                    } catch {
                        print(error)
                        completion(nil)
                    }
                }
            } else {
                print(error)
                completion(nil) 
            }
        }
        task.resume()
    }
}

 

차이가 조금 보이시나요? ㅎㅎ

저도 아직 감은 잡히지 않았지많,, 완료 핸들러를 추가하였습니다.

흠 추가한 이유는! 4개의 보기의 단어를 검색해서 가져온 데이터를 저장해 주기위해서 입니다,,

 

추가한 코드를 설명해 드리겠습니다.

 

QuizViewModel.swift

위에서 보여진 앱은 QuizView.swift 입니다.

이 뷰에 보여지는 데이터 추가를 위해 QuizViewModel을 추가하였는데요!

 

프로퍼티

let hardKoreanWords = HardKoreanWords()
let wordNetwork = WordNetwork()

var choiceWords = [String]()
var correctWord: String = ""
var correctWordDefinition: String = ""
var wordDataDictionary = [String: WordData]()

@Published var isLoading = false

 

어려운 단어를 하드코딩해서 넣어둔 hardKoreanWords

우리샘 api를 사용하여 네트워크 통신을 하는 wordNetwork

정답인 단어를 넣어두는 choiceWord

정답인 단어의 뜻을 넣어두는  correctWordDefinition

보기의 단어들을 넣어두는 딕셔너리인wordDataDictionary
등이 있습니다!
(isLoading은 프로그래스를 추가해서 검색하는 시간동안 보여주려 하는 함수이지만,, 아직 작동은 안하는..)

 

 

보기생성 generateChoices()

func generateChoices() {
    choiceWord.append(correctWord)

    while choiceWord.count < 4 {
        let randomWord = hardKoreanWords.hardWords.randomElement()!
        if !choiceWord.contains(randomWord) {
            choiceWord.append(randomWord)
        }
    }
    //보기 섞기
    choiceWord.shuffle()

    //단어 검색 및 데이터 저장
    for word in choiceWord {
        wordNetwork.searchWord(word) { wordData in
            self.handleWordData(word: word, wordData: wordData)
        }
    }
    print(choiceWord)
}

보기를 생성하는 메소드 입니다.

for word in choiceWord를 위해서 앞서 말한 (URLSession을 통해 api통신을 하는 코드중 수정)부분 입니다.

완료 핸들러를 넘겨 받아서 handleWordData를 통해

wordDataDictionary에 보기 단어를 저장합니다.

 

단어 데이터 처리 handleWordData()

func handleWordData(word: String, wordData: WordData?) {
        if let wordData = wordData {
            wordDataDictionary[word] = wordData
            // 정답 단어의 설명을 가져오기
            if word == correctWord {
                fetchCorrectWordDefinition()
            }
        } else {
            print("\(word) 단어 데이터를 가져오지 못함 ")
        }
    }

URLSession을 통해 api통신을 하는 코드를 완료하게 된다면

handleWordData를 호출하여 wordDataDictionary에 저장을 합니다.

 

정답 단어의 설명 가져오기fetchCorrectWordDefinition()

func fetchCorrectWordDefinition() {
    isLoading = true

    // choiceWords 배열에서 랜덤하게 정답 선택
    correctWord = choiceWord.randomElement() ?? ""

    guard let wordData = wordDataDictionary[correctWord] else {
        correctWordDefinition = "설명을 가져올 수 없습니다."
        isLoading = false 
        return
    }

    if let firstSense = wordData.channel.item.first?.sense.first {
        correctWordDefinition = firstSense.definition
        print(correctWordDefinition)
    } else {
        correctWordDefinition = "설명을 찾을 수 없습니다."
    }

    isLoading = false 
}

4개의 단어중 무작위로 정답을 선택합니다.

guard let은 프로그래스뷰를 설정하기 위해 추가를 하였지만,,,ㅎ 아직 성공은 못했습니다.

데이터가 있는 경우 첫번째의 뜻을 correctWordDefinition에 추가합니다. (정렬이 우리말샘순 이여서 첫번째가 가장 적합하였습니다.)

데이터가 없을 경우는 설명을 찾을 수 없습니다 의 내용을 저장합니다.

 

정답 확인 메서드 checkAnswer()

func checkAnswer(selectedWord: String) -> Bool {
    return selectedWord == correctWord
}

 

선택된 단어가 정답과 일치하는지 확인하는 메서드 입니다.

 

 

init()

init() {
    correctWord = hardKoreanWords.hardWords.randomElement() ?? ""
    generateChoices()
}

 

클래스의 인스턴스가 생성될때 호출되는 init입니다.

generateChoices()메서드를 호출하여 선택지를 생성하며 단어의 데이터를 검색하고,저장할 수 있도록 합니다.

 

QuizView.swift

VStack{

    Text("QuizView")
        .padding(.top, 50)

    Spacer()

    if quizViewModel.isLoading {
        ProgressView("Loading...")
    } else {
        Image(systemName: "square.and.arrow.up")
        Text(quizViewModel.correctWordDefinition)
    }

    Spacer()

    VStack(){

        HStack {
            ForEach(quizViewModel.choiceWord.prefix(2), id: \.self) { word in
                Button(action: {
                    // 선택된 단어에 대한 동작을 추가하세요
                    isAnswerCorrect = quizViewModel.checkAnswer(selectedWord: word)
                    print("선택된 단어: \(word), 정답?: \(isAnswerCorrect)")
                }) {
                    Text(word)
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(8)
                }
                .padding()
            }
        }

        HStack {
            ForEach(quizViewModel.choiceWord.dropFirst(2), id: \.self) { word in
                Button(action: {
                    // 선택된 단어에 대한 동작을 추가하세요
                    isAnswerCorrect = quizViewModel.checkAnswer(selectedWord: word)
                    print("선택된 단어: \(word), 정답?: \(isAnswerCorrect)")
                }) {
                    Text(word)
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(8)
                }
                .padding()
            }
        }
    }

    Text(isAnswerCorrect ? "정답입니다!" : "오답입니다.")

    Spacer()
}//VStack

 

quizview에서 간단하게 보기 단어를 4개 보여주고 정답유무를 확인하는 텍스트를 추가하였습니다 하하..

아직

Image(systemName: "square.and.arrow.up")

를 완성하지 않았는데요!

이부분은 네이버 검색API를 활요해서 정답 단어의 관련 이미지를 보여줄 예정입니다 ㅎㅎ..

 

 

이 정도 구현하는것도 어렵네요,, 하하,,,

저의 수준을 잘 파악 할수 있는 시간이 되었습니다 ㅎㅎ

조금은 SwiftUI와 친숙해진 시간이 된것 같네요??!!

 

다음은 네이버 검색 API를 활용한 글을 가져오겠습니다...

이상 보고 끝!(화이팅,,!👏)

https://simth999wrld.tistory.com/74

 

1차시. OT

간단하게 스터디 일정과 인사후 개인프로젝트 소개를 하였습니다, ^_^/ 간단하게 아이디어를 정리해보았습니다.. 진행과정을 간간히 올려보도록 노력! 프로젝트 개요 앱 이름: ??? 프로젝트의 동

simth999wrld.tistory.com

 

위를 바탕으로 개발을 시작하였습니다.

 

1.  시장조사

 

제가 구상한 퀴즈 앱을 구상하기 위해 앱스토어를 둘러보았습니다 하하.. (대충 요런것 둘러봤다는 뜻)

 

2. 뷰 구성

둘러보며 간단하게 저의 앱의 뷰를 구성하였는데요,

 

홈뷰

오늘의 단어와 뜻을 간단하게 알려줌.

퀴즈뷰

퀴즈 시작 시 단어의 뜻과 관련 사진을 알려주고 단어를 맞추는 방식.

정답 여부와 관계없이 TTS로 단어를 읽어줍니다. 

알림뷰

오늘의 단어를 알림을 통해 사용자에게 알려주기

알림 시간 설정 기능

알림 ON/OFF 기능

 

이렇게 3개의 뷰로 구성할것입니다.

+오늘의 단어와 뜻 보여주는 위젯 구성하기.

 

3. 우리말샘 API호출 테스트

단어와 뜻을 가져오기 위해 우리말샘의 API를 사용하였습니다.

 

사이트에서 저의 API키를 받고 openAPI 예시를 "미끼" 로 확인하였습니다.

 

 

위와 같이 JSON을 불러와야 하니 DataModel을 구성하였구요

struct WordData: Codable {
    let channel: Channel
}

struct Channel: Codable {
    let item: [Item]
}

struct Item: Codable {
    let word: String
    let sense: [Sense]
}

struct Sense: Codable {
    let definition: String
    let pos: String?
    let link: String
    let type: String
}

 

 

 

우리말샘 사용을 위해 UrlSession 네트워크 통신을 하였습니담~

class WordNetwork: ObservableObject {
    
    @Published var wordData: WordData?
    
    func searchWord(_ searchWord: String) {
        let urlString = "https://opendict.korean.go.kr/api/search?certkey_no=6282&key=\(YourApiKey)&target_type=search&req_type=json&part=word&q=\(searchWord)&sort=dict&start=1&num=10"
        if let url = URL(string: urlString) {
            let session = URLSession(configuration: .default)
            let task = session.dataTask(with: url) { data, response, error in
                if error == nil {
                    if let safeData = data {
                        do {
                            let decodedData = try JSONDecoder().decode(WordData.self, from: safeData)
                            DispatchQueue.main.async {
                                self.wordData = decodedData
                            }
                        } catch {
                            print(error)
                        }
                    }
                }
            }
            task.resume()
        }
    }
}

 

 

네트워크 통신을 통해 단어를 검색하여 단어와 뜻을 가져올 수 있게 되었습니다.

 

 

 

 

간단하게 버튼을 활용해서 "살림"이란 단어를 검색하여
테스트 하였습니다.

 

 

 

 

 

4. 문제발생

4.1) 시뮬레이터 잘려보이는 현상

 

 

 

 

 

 

 

 

앱을 빌드하니, 시뮬레이터에서 화면이 꽉차지 않는 현상이 있더라군요,,

이는 간단히 앱의 General-AppIcons and Launch Screen

의 Launch Screen File을 main 또는 LaunchScreen으로 하면 된다고 합니다 하하...😅😅

 

 

 

 

 

 

4.2) 고급 단어 어떻게 가져올까...?

고급 단어를 어떻게 가져올까...합니다..

이는 아직 해결은 안됐습니다,,,

그래서! 원시적인 방법으로 문제해결 같아 보이게 하였는데요,,

 

 

 

 

 

 

하드코딩을 하여 조금 어려울만한 단어들을 검색해 넣어두었습니다. ^_^

이 문제에 대해 아이디어가 있으면 알려주세요,,ㅜㅠ

 

 

 

 

 

 

5. 다음 할일

api호출을 위한 테스트를 해보았구요. 이제 기능들을 차차 구현해 나갈 계획입니다.

퀴즈뷰 부터 시작 하려합니다.

예상은 단어의 뜻과 관련 이미지를 보여주고, 4개의 보기중 정답인 단어를 맞추는 식으로 할것같은데,,

보기의 오답 단어는 어떻게 할까~ 하다 정답과 비슷한 유의어를 보여줄것 같습니다!

이것또한 오픈api를 사용 하려 합니다.

정답 단어의 관련 이미지는 네이버 이미지검색 api를 활용하면 좋을것 같습니다 하하~~

어려운 단어 어떻게 가져올지 알려줘 젭알

 

 

 

https://opendict.korean.go.kr/main

 

우리말샘 - 함께 만들고 모두 누리는 우리말 사전

 

opendict.korean.go.kr

https://aiopen.etri.re.kr/guide/Homonym

 

AI API/DATA

JSON parsing을 위해 Gson 라이브러리를 사용하여 제공하고 있습니다. Gson 라이브러리에 대한 자세한 설명은 https://github.com/google/gson 에서 확인 하실 수 있습니다. import java.io.DataOutputStream; import java.io.

aiopen.etri.re.kr

https://developers.naver.com/docs/serviceapi/search/image/image.md#%EC%9D%B4%EB%AF%B8%EC%A7%80-%EA%B2%80%EC%83%89-%EA%B2%B0%EA%B3%BC-%EC%A1%B0%ED%9A%8C

 

검색 > 이미지 - Search API

검색 > 이미지 이미지 검색 개요 개요 검색 API와 이미지 검색 개요 검색 API는 네이버 검색 결과를 뉴스, 백과사전, 블로그, 쇼핑, 영화, 웹 문서, 전문정보, 지식iN, 책, 카페글 등 분야별로 볼 수

developers.naver.com

 

ToDo
✅ 운동
✅ 치즈케이크먹기 ㅋ

 

SwiftUI를 공부흐는 증읍느드.... uikit보다 재미가 느껴지네욤 아직 uikit은 마스터하진 못하였지만

흥미도 조금있어야 스트레스를 덜받겠죠 ㅎ.. 암튼~

곧 있으면 크리스마스네요 ^_^ 모두들 메리 크리스마스~~ (이브와 당일엔 등장하지 않습니다..)

 

배운점

https://simth999wrld.tistory.com/60

 

TIL_20231113

짧은인사, 하루의 사건, 나의 행동 ToDo ✅ udemy 완강 끄읕 ✅ 패캠 강의 ✅ 운동 가을은 어디갔죠...? 하ㅏㅎ... 날씨가 이리 추울 수 있나요ㅋㅋ 열어분 모두 감기 조심하세요~~ 또 건조해져서 가습

simth999wrld.tistory.com

 

오늘은 SwiftUI의 Property Wrappers를 알아보았습니다. 이걸 모르면 아무것도 못하것죠??

한번 보았던 내용이니 후딱 하고 넘어가겠습니다. 리마인드 하는 마음으로 보자구요

Property Wrapper

반복되는 코드를 간단하게 적용하기 위해 사용되는 문법입니다.

 

 

 

@State

 

@State는 특적 프로퍼티를 뷰의 상태(state)로 만들어줍니다.

무슨소리일까요??

간단하게 변수값이 변경이 되면 자동으로 뷰도 변경이 일어납니다.

 

@State var leftDiceNumber = 1
@State var rightDiceNumber = 1

var body: some View {
    DiceView(n: leftDiceNumber)
    DiceView(n: rightDiceNumber)

    Button {
    self.leftDiceNumber = Int.random(in: 1...6)
    self.rightDiceNumber = Int.random(in: 1...6)

    } label: {
        Text("Roll")
    }
}

 

간단한 코드입니다. VStack등 제끼구 봅시다.

 

leftDiceNumber와 rightDiceNumber는 @State로 선언되어 있습니다.

"Roll"의 버튼이 눌리게 된다면 1~6의 숫자가 랜덤하게 들어가며 주사위의 이미지가 바뀌게 됩니다.

 

만약 @State로 선언되어 있지 않다면 아무리 Roll을 하게되더라도 데이터와 뷰가 바뀌지 않게됩니다.

 

 

 

@Binding

 

@Binding은 다른 인스턴스 소유의 @State 프로퍼티를 가져올때 사용합니다.

struct MyToggleButton: View {
    @Binding var value: Bool

    var body: some View {
        Button(action: {
            self.value.toggle()
        }, label: {
            Text(self.value ? "Hello" : "World")
        })
    }
}

struct ContentView: View {
    @State private var value = false

    var body: some View {
        VStack {
            MyToggleButton(value: $value)
        }
    }
}

위의 코드에서 MyToggleButton의 struct의 value 프로퍼티가 @Binding으로 선언되어있습니다.

이 프로퍼티는 ContentView에서의 뷰가 생성될때 value프로퍼티와 연결되게 됩니다.

 

따라서 두 데이터는 연결되어 한쪽값이 바뀌어도 다른 한 값이 바뀌게 됩니다. 뷰또한 업데이트 됩니다.

 

@State, @Binding은 value타입 즉 값 타입이기에 reference type참조 타입일때는 사용을 하지 못한다는 단점이 있습니다.

참조타입인 클래스에선 사용을 하지 못합니다.

 

 

@ObservedObject

 

@ObservedObject 라는 Property Wrapper를 사용하여 @State, @Binding의 단점을 극복할 수 있습니다.

@ObservableObject를 상속받은 클래스의 프로퍼티는 @ObservedObject 의 프로퍼티 래퍼를 적용하여 뷰와 프로퍼티를 연결시킬수 있습니다.

추적을 원하는 프로퍼티는 @Published의 프로퍼티 래프를 작성해야 합니다.

class MyData: ObservableObject {
    @Published var name = "World"
    @Published var buttonTitle = "Switch to Universe"

    func switchName() {
        if name == "World" {
            name = "Universe"
            buttonTitle = "Switch to World"
        } else {
            name = "World"
            buttonTitle = "Switch to Universe"
        }
    }
}

struct ContentView: View {
    @ObservedObject var data = MyData()

    var body: some View {
        VStack {
            Text("Hello, \(data.name)!")
                .padding()
            Button(
                action: { self.data.switchName() },
                label: { Text(self.data.buttonTitle) }
            )
        }
    }
}

 

 

 

@EnviromoentObject

@EnviromoentObject는 클래스 오브젝트를 추적하기 위한 용도의 프로퍼티 래퍼이다.

ObservableObject를 통해 구현된 타입의 인스턴스를 전역으로 공유하여 사용

앱 전역에 공통으로 사용할 데이터를 주입 및 사용

class SharedData: ObservableObject {
    @Published var configName = "default"
    ...
}

struct ContentView: View {
    @EnvironmentObject var sharedData: SharedData
    ...
}

struct FooView: View {
    @EnvironmentObject var sharedData: SharedData
    ...
}

SharedData를 ObservableObject로 선언후, ContentView와 FooView에서 @EnviromoentObject의 형식으로

참조한다. 이는 여러 곳에서 공유될 만한 데이터를 관리하는 모델로 사용하기 좋다.

 

SharedData를 참조가 시작되기 전에 생성해야한다.

보통은 해당 뷰를 만들기 전에 오브젝트를 생성하고 이것을 enviromentObject()로 알려주어야 한다.

 

var sharedData = SharedData()
...
window.rootViewController =
    UIHostingController(rootView: ContentView().environmentObject(sharedData))

위의 코드가 SharedData 오브젝트를 생성해서 공유를 시작하는 시점이다.

 

SceneDelegate.swift에서 설정.

https://leetaek.tistory.com/67

 

 

 

 

@StateObject (https://pilgwon.github.io/post/state-object-vs-observed-object)

@StateObject는 @ObservedObject와 비슷한 특징을 가졌지만 SwiftUI의 라이프싸이클을 관리하는 

방식에 차이점이 있습니다.

 

뷰에서 ObservableObject 타입의 인스턴스 선언 시 사용할 수 있음.

 

뷰마다 하나의 인스턴스를 생성하며, 뷰가 사라지기 전까지 같은 인스턴스 유지

@ObservableObject의 뷰 렌더링 시 인스턴스 초기화 이슈 해결을 위한 방법

 

매번 인스턴스가 새롭게 생성되는것처럼 외부에서 주입 받는 경우가 아닌 최초 생성 선언 시에 @StateObject를 사용하는 것이 적절한 방법

 

위의 특징이라 저번TIL에 작성을 하였었는데, 흠 너무 추상적으로 적어두어서 다시보니 이해가 가질 않습니다.

 

SwiftUI가 화면을 만들거나 다시 그릴수 있는 가능성이 있다면 인스턴스가 초기화가 되지 않는 @StateObject를 사용한다고

다시 이해를 하였습니다 ^_^

의존 관계로 주입한다면 @ObservedObject

 

생각과 감정

고독합니다.. 고독.... 누군가와 같이 스터디를 하고싶슴다... 프로젝트도 하고싶고... 으악...

앞으로의 계획선언

일단 크리스마스를 즐겨보자구요 ^_^

 

 

 

 

 

(보면서 공부했슴다.. 감삼다!)

https://seorenn.github.io/note/swiftui-property-wrappers.html

 

SwiftUI Property Wrappers - Seorenn Note

@State 는 특정 프로퍼티를 뷰의 상태(state)로 만들어준다. 즉 이 프로퍼티가 변경되면 자동으로 뷰의 데이터도 변경되고, 뷰의 데이터를 바꿔도 이 프로퍼티의 데이터도 자동으로 변경된다. struct

seorenn.github.io

 

'TIL' 카테고리의 다른 글

TIL_20240103  (0) 2024.01.05
TIL_20231226  (0) 2023.12.26
TIL_20231221  (0) 2023.12.21
TIL_20231212  (0) 2023.12.12
TIL_20231126  (0) 2023.11.26

+ Recent posts