https://wiwi-pe.tistory.com/275

 

background audio play - 7 [Custom Modifier]

https://wiwi-pe.tistory.com/274 background audio play - 6 [오디오 인터럽트]https://wiwi-pe.tistory.com/261 background audio play - 5 [SwiftUI로 변경]0. 시작한참동안 또 안건드리다가 다시 잡게되었다.이전에 잠금화면에서

wiwi-pe.tistory.com

 

평소에는 그냥 흘러가는데로 음악을 듣다가 최근 발묘라는 노래에 꽂혀 한곡만 듣고싶어졌다

처음 만들땐 재생 모드가 필요할가 싶었는데 

설마 필요해질줄이야 

아무튼 그래서 한곡 재생 만드는김에 전체 루프도 추가했다

 

우선 모드들 보기 편하게 enum으로 만들주고

/// 0이 노말, 루프, 원루프 순 ++
enum PlayMode: Int, CaseIterable {
  // normal > 음악목록 1회씩 재생,loop > 음악목록 끝나면 처음으로 가서 재생, one_loop > 현 재생 곡 계속 반복
  // 전체 반복이 그냥 loop로 해놔도 되나?
  case normal = 0, loop, one_loop
}

0값을 넣어준이유는 그냥 모드 변경할때 +으로 하고싶어져서인 별 다른 이유없다 

재생 모드들을 영어로 뭐라하는지 몰라서 그냥 loop, one loop로 해놨음... 귀찮

그리고 모드값 저장할 변수,

  @Published private(set) var playMode: PlayMode = .normal

퍼블리쉬 달아준건 당연히 컨트롤바나 리스트에서 변경됨을 감지하기위함이고...

이건 참 편하다 예전의 나였으면

didset { notifiacation ~~ 블라블라 } 이걸 달아주고 옵저버로 받으니 어쩌니 했을텐데 

그냥 퍼블리쉬 달아주면 뷰에서 감지하고 뷰업데이트해주니 좋아졌다

 

뭐 아무튼 

모드 변경할 함수도만들어주고

  
  func chnagePlayeMode() -> Bool {
    let oldValue: Int = self.playMode.rawValue
    let modeCount: Int = PlayMode.allCases.count
    let newValue = oldValue + 1
    
    
    if newValue < modeCount, let newMode = PlayMode(rawValue: newValue) {
      playMode = newMode
      return true
    }
    else if newValue == modeCount, let newMode = PlayMode(rawValue: 0) {
      // 노말 모드로 변경
      playMode = newMode
      return true
    }
    else {
      // 에러
      print("error: 이상한 값이 되었다? oldValue:\(oldValue), newValue\(newValue), modeCount:\(modeCount)")
      return false
    }
    
//    return false
  }

외부값이라곤 그냥 이넘 모드값 가져오는거 말곤 딱히 없겠지만 그래도 혹시 모를 디버그용 모드값 출력도 달아주고

오디오 종료감지를 하는 델리게이트쪽 함수 수정....

하려니 nextMusic()쪽에서 마지막 음악을 감지하고 종료하게 해놧던게 생각나서 수정하고 끝!

하려니... 한곡 반복에 대한 플레이용이 없어서 해당 기능을 분리 및 변경해주고 끝!

플레이쪽
  // musicData가 비었거나 마지막 인덱스라면 동작하지 않도록 isLast 검사
  // + normal 일때만 라스트 검사 하기
  @objc func nextMusic() -> Bool {
    if playMode == .normal {
      guard !isLast else { return false }
    }
    
    _ = next()
//    let url = currentMusic?.url
//    guard url != nil else {
//      print("play url is nil")
//      stopUpdateTimer()
//      return false
//    }
//    
//    return play(url: url!)
    return currentMusicPlay()
  }
  
  func currentMusicPlay() -> Bool {
    let url = currentMusic?.url
    guard url != nil else {
      print("play url is nil")
      stopUpdateTimer()
      return false
    }
    return play(url: url!)
  }
  

... ~~~~

~~~~

델리게이트쪽

func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
    switch playMode {
    case .normal, .loop:
      _ = nextMusic()
    case .one_loop:
      _ = currentMusicPlay()
      
    }
}

 

얼추 다른 플레이어랑 비슷하게 되어버렸다

그리고 이제 슬슬 앱 아이콘이 없는게 거슬려졌다

아이콘 만들어주는 무료사이트가 아직 살아있을지 모르겠네 

쓰다보니 컨트롤바 버튼 만들어준걸 깜빡했는데 그냥 다른 버튼 만든 함수 그대로 쓰고 

특수문자랑 숫자만 써서 표현해줬다 

아이콘주면 더 이쁠거같긴한데 귀찮기도하고 만들기 싫고..

 

https://github.com/wiwi-git/proj_ypl

 

GitHub - wiwi-git/proj_ypl: ios에서 간단하게 쓸 백그라운드 플레이어가 필요하다

ios에서 간단하게 쓸 백그라운드 플레이어가 필요하다. Contribute to wiwi-git/proj_ypl development by creating an account on GitHub.

github.com

 

반응형

'iOS > swift' 카테고리의 다른 글

background audio play - 7 [Custom Modifier]  (0) 2025.08.20
background audio play - 6 [오디오 인터럽트]  (1) 2025.08.19
맥os 위젯 만들기 - 2  (2) 2025.08.01
맥os 위젯 만들기 테스트  (2) 2025.07.23
CoreData with SwiftUI  (0) 2025.05.09

https://wiwi-pe.tistory.com/274

 

background audio play - 6 [오디오 인터럽트]

https://wiwi-pe.tistory.com/261 background audio play - 5 [SwiftUI로 변경]0. 시작한참동안 또 안건드리다가 다시 잡게되었다.이전에 잠금화면에서 재생화면으로 뜨는거와 mvvm으로 나눠둔것까지 했었다. 기능적

wiwi-pe.tistory.com

 

어제에 이어 앱 편의성좀 올려볼까해서 업데이트해봤다

내 앱은 특정 음악을 듣고싶으면 옛날 카세트 테이프 마냥 앞뒤 이동으로 곡을 찾아서 돌려야했는데 

많은 노래를 듣지않고 듣던 노래만 많이 듣는사람이기에 리스트 선택으로 곡 재생하는 기능은 딱히 넣지 않았다.

그럴려고 했으나...

어제 막상 앱 업데이트를 하고나니 뭔가 아쉬워서 추가해봤다.

 

우선 기존의 내 플레이리스트뷰 코드

    List(player.musicData) { item in
      Text(item.title)
    }
    .listStyle(.automatic)
    .onAppear(perform: {
      player.loadList()
    })

정말 text만 넣어서 필요한 기능은 다했다 라는 느낌이 드는 이 리스트에서 선택기능을 넣어줘야한다

리스트에서 이러한 기능을 넣으려면 보통 List의 selection 파라미터를 사용하고 이를 onChange() 라는 수정자로 감지하면 된다는데... 

하는 방식이 참 뭔가 아쉽다. 예전 uikit의 uitableview의 셀선택이 훨씬 깔끔하고 예뻐보이는데, 아직 내가 좋아보이는 방법을 못본거지 뭔가 더 좋게 하는사람이 있을지도 모르겠다.

아무튼 이 onChange는 중간에 또 업데이트가 생겼는지

기존 onChange(of:perform:)는 

Deprecated

Use onChange(of:initial:_:) or onChange(of:initial:_:) instead.

https://developer.apple.com/documentation/SwiftUI/View/onChange(of:perform:)

 

onChange(of:perform:) | Apple Developer Documentation

Adds an action to perform when the given value changes.

developer.apple.com

 

 

라고한다.

그런데 이건또 내가 미니멈을 14로 잡고 진행중인 앱이라 바로 못쓴다.

 

@avaliable(ios 17.0, *) 뭐 이런걸 뷰에다가 붙여서 두개로 나눠줘도 되는데 뭔가 싫어서 gemini에게 물었더니 

커스텀 수정자를 이용하라고한다

뭐 대충 

.modifier() 이렇게 생긴애인데

여기 안에다가 ViewModifier 라는 프로토콜을 받는 struct를 넣으면 짜잔~ 

이라는 느낌

 

아무튼 그래서 selection 에서 값을 받기위해 state 프로퍼티 래퍼붙은 변수를 하나 만들어주고 

 @State private var selectItemID: UUID?

onChange용 커스텀수정자도 만들어서 추가해주면!

struct ExcuteOnChangeModifier: ViewModifier {
  @Binding var selectedItemID: UUID?
  let executeAction: (UUID?) -> Void
  
  func body(content: Content) -> some View {
    if #available(iOS 17.0, *) {
      // iOS 17.0 이상
      //onChange<V>(of value: V, initial:, _ action:)
      content.onChange(of: selectedItemID, initial: false) { oldID, newID in
        executeAction(newID)
      }
    } else if #available(iOS 14.0, *) {
      // iOS 14.0 ~ 16.x:
      // onChange<V>(of value: V, perform action: )
      content.onChange(of: selectedItemID) { newID in
        executeAction(newID)
      }
    } else {

    }
  }
}

라고 생각하니 재생에 관한 문제가 발생한다

아직 난 stop에 관한 기능이 없었다 다시 musicplayer로 가서 stop을 만들어주고 ...

또 보니 currentIndex 바꿔주는 부분도 없었고..

라고 보니 index를 private화 해줘서 보호하고싶어서 변수를 따로 분리해놔서 publish 래퍼가 안붙는다

해서 기존 index와 currentIndex를 합치고 set부분만 private화 하는걸로 또 수정주고...

미래의 내가 또 헷갈리진 않을까 걱정하여 잔소리도 좀 주석으로 달아줬다 

짠!

  func stop() -> Bool {
    defer {
      NotificationCenter.default.post(name: .changedContInfo, object: nil)
    }
    avPlayer.stop()
    paused = false
    isPlaying = false
    stopUpdateTimer()
    
    return true
  }
  
  
  @Published private(set) var currentIndex: Int = 0
  
  // index만 변경되서 다음곡으로 넘어가는 등의 이벤트가 겹칠경우 문제가 발생할수 있다 변경하기전에 플레이를 중지시키자
  func setCurrentIndex(at new: Int) {
    print("called setCurrentIndex \(new)")
    
    guard !musicData.isEmpty,
          new >= 0,
          new < musicData.count else { return }
    currentIndex = new
  }

 

그리고 뷰를 수정해줬다 짠!

 

  var body: some View {
    List(player.musicData, selection: $selectItemID) { item in
      Text(item.title)
    }
    .listStyle(.plain)
    .onAppear(perform: {
      player.loadList()
    })
    .modifier(ExcuteOnChangeModifier(selectedItemID: $selectItemID, executeAction: selectListAction))
  }
  
  func selectListAction(_ id: UUID?) {
    // 선택 하이라이트가 유지되지 않게 하고싶었는데 스타일이 딱히 뭐 안보이더라 그냥 nil 처리
    self.selectItemID = nil
    _ = player.stop()
    if let index = player.musicData.firstIndex(where: { music in
      music.id == id
    }) {
      player.setCurrentIndex(at: index)
    }
    _ = player.play()
    
  }

커스텀 수정자는 좀 맘에드는듯 

 

지금보니 컨트롤바버튼 기능을 반환값을 받게 만든게 후회된다.

원래는 중간에 뭔가 에러가 발생하면 발생값을 줄려고 이렇게 만들었었는데 해당부분에선 딱히 뭔가 없었다

아무튼 

 

끝!

반응형

'iOS > swift' 카테고리의 다른 글

background audio play - 8 [반복 재생]  (0) 2025.08.23
background audio play - 6 [오디오 인터럽트]  (1) 2025.08.19
맥os 위젯 만들기 - 2  (2) 2025.08.01
맥os 위젯 만들기 테스트  (2) 2025.07.23
CoreData with SwiftUI  (0) 2025.05.09

https://wiwi-pe.tistory.com/261

 

background audio play - 5 [SwiftUI로 변경]

0. 시작한참동안 또 안건드리다가 다시 잡게되었다.이전에 잠금화면에서 재생화면으로 뜨는거와 mvvm으로 나눠둔것까지 했었다. 기능적으론 딱히 더 필요한게 재생바에서 재생시간옴기는거려나

wiwi-pe.tistory.com

 

8월... 시끄러운 매미울음소리 지글지글 콘크리트 바닥에서 올라오는 열기 그리고 면접철

면접만 아니였어도 낮엔 밖으로 나가질 않았을텐데 그렇게 8월들어 밖으로 나갈일이 종종 생겼다

사람끼리 끼이는 전철을 다시 이용해야한다는 끔찍함으로 다 포기하고 생을 마감하고 싶어질쯤 직접 만든 앱이 유용하게 써먹을일이 생긴거에 살짝 즐거움을 느끼게되었고 삶을 좀 더 이어나가게되었다

별 다른 기능이 없는 음악 플레이어 답게 인터럽트 처리도 되지 않아서 매번 톡이나 긴급문자들이 올때마다 음악이 정지되는게 짜증이 났고 

오늘 심심해진김에 이 기능을 추가!

 

예전이라면 

audioPlayerBeginInterruption(_ player: AVAudioPlayer) {}

 audioPlayerEndInterruption(_ player: AVAudioPlayer, withOptions flags: Int) {}

이 두개로 해줬을텐데 쓰려고하니 DEPRECATED!!!! 경고가 날 못살게군다

다행이도 메시지로 뭘 쓰라고 알려준다

"Use AVAudioSession instead"

안내해준 메시지대로 검색하거나 ai에게 물어보거나 하면 아마 

https://developer.apple.com/documentation/avfaudio/handling-audio-interruptions

 

Handling audio interruptions | Apple Developer Documentation

Observe audio session notifications to ensure that your app responds appropriately to interruptions.

developer.apple.com

해당 페이지의 내용을 그대로 써주지 않을까 싶다 . 그렇게 이 내용대로 수정을 살짝 가해주면 

  private func initPlayer() {
    let audioSession = AVAudioSession.sharedInstance()
    do {
     ~~~~~~~~~~~~~~~~~~~
      try audioSession.setActive(true)
      
    } catch let error as NSError {
      print("audioSession 설정 오류 : \(error.localizedDescription)")
    }
    
    NotificationCenter.default.addObserver(self,
                                           selector: #selector(handleInterruption),
                                           name: AVAudioSession.interruptionNotification,
                                           object: AVAudioSession.sharedInstance())
  }
  
  @objc func handleInterruption(notification: Notification) {
    guard let userInfo = notification.userInfo,
          let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
          let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
      return
    }
    
    switch type {
    case .began:
      if self.isPlaying {
        _ = self.pause()
      }
      
    case .ended:
      // An interruption ended. Resume playback, if appropriate.
      
      guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
      
      let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
      
      if options.contains(.shouldResume) {
        // An interruption ended. Resume playback.
        _ = self.play()
      } else {
        // An interruption ended. Don't resume playback.
      }
    default:
      break
    }
  }

기존에 카테고리 설정에서 옵션값을 주면 컨트롤바가 안나오는 이상한 버그에  setActive도 같이 지웠었는지 일단 추가해줬고...

(본래대로 라면 setActive를 해제하는것도 있어야하지 않을까 싶은데 귀찮고 졸려서 넘어갔다)

옵저버로 AVAudioSession.interruptionNotification 를 구독하고 

AVAudioSessionInterruptionTypeKey값으로 들어오는 값을 파싱(뭔가 타입을 따로 만들어줘서 해야해줄거 같은데 사실 값을 보면 0 아니면 1 두개뿐이라 저렇게 해줘야할까 싶기했다만 보기 좋으니 그냥 따라해줬다)

인터럽트가 끝나서 end값이 온다면 play()함수를 호출하도록 해줬다

그런데 이게 또 설명에선 항상 저 값이 온다는 보장이 없다고하니... 도대체 왜? 라는 의문이 드는 예제였다

뭐 아무튼 딱히 추가된건 별거 없이 인터럽트 처리 끝

 

반응형

'iOS > swift' 카테고리의 다른 글

background audio play - 8 [반복 재생]  (0) 2025.08.23
background audio play - 7 [Custom Modifier]  (0) 2025.08.20
맥os 위젯 만들기 - 2  (2) 2025.08.01
맥os 위젯 만들기 테스트  (2) 2025.07.23
CoreData with SwiftUI  (0) 2025.05.09

이전에 단순히 에셋폴더에 이미지를 넣어서 위젯으로 띄우는 걸 해봤다. 

https://wiwi-pe.tistory.com/269

 

맥os 위젯 만들기 테스트

바탕화면에 귀여운 이미지를 배치해놓고 감상하는 이상한 취미를 가진사람으로서 맥이 세콰이어(발음이 맞나모르겠다, 14버전)부터 아이폰처럼 위젯을 배치할수게된 기능을 못본척 넘어갈수없

wiwi-pe.tistory.com

 

에셋에 이미지를 넣어서 만들면 이거저거 신경쓸거 없이 그냥 이미지뷰에 바로 연결해주면 따단 하고 띄여주기에 정말 편해서 좋지만, 안타깝게도 내가 원하는 이미지가 바뀔때 마다 이미지를 새로 넣어서 빌드해줘야하는 버거로움이 생긴다.

그래서 이번엔 위 프로젝트에서 거의 사용을 안하던 메인앱에서 이미지를 지정, 위젯으로 업데이트 해주는 방식으로 변경하려고한다.

그리고 이번에도 역시 직접 코딩은 찾아보면서 해야기에 귀찮아서 ai에게 맡긴다

저번엔 chat gpt에게 부탁했지만 답답함이 도를 넘었었기에 이번엔 gemini를 이용했다.

gemini 만세

xcode 확장으로도 아마 있을거 같지만 그냥 cli를 npm으로 설치해줘서 물어봤다.

아래 이미지는 한번 만든후 이상하게도 동작하지 않는 문제가 발생했을때 물어본 질문이다.

별거 없이 그냥 프로젝트에 대한 설명, 어떤 기능이 동작하지 않는지, 그리고 대망의 프로젝트 패스를 알려주면 알아서 폴더째로 읽어내서 분석하는 놀라운 장면이 담긴 이미지다.

 

이미 이번 글쓸 부분을 끝낸 상태에서 이미지를 찍은거라 딱히 중간 과정은 없다.

이 gemini가 무서운점이 별도의 권한을 주지 않고 패스를 주면 알아서 폴더째로 읽어간다는 점이다.

그렇게 내 프로젝트는 폴더째로 구글 서버로....

이거를 살짝 포장해서 쓰기 편하게 ui입힌 프로젝트들이 좀 돌아다니던데 이거 악용하면 컴퓨터 내부 폴더들 죄다 쓸어갈수 있지 않을까 하는 조금 무서움이 있었다.

아무튼 잡썰은 그만 풀고

크게 달라진점은

Hello world만 나오던 메인앱에 ui가 생긴점이고

var body: some View {
    VStack {
      HStack {
        Button {
          openPanel()
        } label: {
          Text("이미지 변경")
        }
        Spacer()
      }
      
      Spacer()
      
      if let image = image {
        Image(nsImage: image)
          .resizable()
          .aspectRatio(contentMode: .fit)
          .frame(maxWidth: .infinity, maxHeight: .infinity)
      } else {
        Text("비어있는 이미지뷰")
          .frame(maxWidth: .infinity, maxHeight: .infinity)
          .background(Color.gray.opacity(0.1))
          .overlay(
            RoundedRectangle(cornerRadius: 8)
              .stroke(Color.gray, style: StrokeStyle(lineWidth: 1, dash: [5]))
          )
      }
      
      Spacer()
    }
    .padding()
    .onAppear(perform: loadImageFromUserDefaults)
  }

openPanel() 로 이미지를 사용자가 지정및 앱에 저장 및 보여주기 그리고 loadImageFromUserDefaults()로 앱이 처음 열렸을때 어떤게 저장되어 있는지 불러와 보여주는 식이다.

  private func openPanel() {
    let panel = NSOpenPanel()
    panel.allowedContentTypes = [.png, .jpeg, .gif, .tiff, .bmp]
    panel.allowsMultipleSelection = false
    panel.canChooseDirectories = false
    
    if panel.runModal() == .OK, let url = panel.url {
        // Start accessing the security-scoped resource.
        guard url.startAccessingSecurityScopedResource() else {
            print("Error: Could not start accessing security-scoped resource.")
            return
        }
        
        // Defer stopping access to ensure it's always called.
        defer {
            url.stopAccessingSecurityScopedResource()
            print("Stopped accessing security-scoped resource.")
        }
        
        // 1. Load the image directly for immediate display (we know this works).
        self.image = NSImage(contentsOf: url)
        
        // --- NEW: Save image data directly ---
        if let image = self.image, let imageData = image.tiffRepresentation {
            if let bitmap = NSBitmapImageRep(data: imageData),
               let pngData = bitmap.representation(using: .png, properties: [:]) {
                UserDefaults(suiteName: "group.desktopw.dy")?.set(pngData, forKey: "widgetImageData")
                print("Successfully saved image data to UserDefaults.")
            } else {
                print("Error converting image to PNG data.")
            }
        } else {
            print("Error getting image TIFF representation.")
        }
        // --- END NEW ---
        
        WidgetKit.WidgetCenter.shared.reloadAllTimelines()
    }
  }
  
  private func loadImageFromUserDefaults() {
    guard let imageData = UserDefaults(suiteName: "group.desktopw.dy")?.data(forKey: "widgetImageData") else {
        print("No image data found in UserDefaults on app start.")
        self.image = nil
        return
    }
    self.image = NSImage(data: imageData)
    print("Successfully loaded image from UserDefaults on app start.")
  }

 

중요한 점은 앱그룹과 userdefaults를 이용한 데이터 저장, 그리고 WidgetKit 의 reloadAllTimelines() 이다. 위젯 소스를 보면 위젯 업데이트를 해주는 부분의 값이 atEnd로 되어있어서 따로 위젯이 업데이트 되도록 잡아줘야한다. 

 

참고로 원래는 북마크 기능을 이용하여 따로 이미지 데이터 전부를 가지고 있지 않으려고 했는데 계속 문제가 발생하여 살짝 포기상태로 글을 쓰고있다. 또 다음에 이거로 글을 쓰게 된다면 해당 부분을 수정할거다, 될지를 모르지만 시큐리티 북마크는 포기하고 이미지 파일 자체를 복사해놨다가 불러오는식으로 하면 되지 않을까 짐작하고있다. 또 마찬가지로 되는지 찾아봐야겠지만 다중 위젯 별 이미지를 다르게도 설정하고싶다.

 

다시 코드로 돌아가자면 이제 위젯 코드쪽이다

이번 프로젝트의 핵심인 기능인데 프로바이더에서 메인앱에서 저장한 잉미지를 로드해서 위젯을 업데이트 해줘야한다.

참고로 로그찍는게 많은부분은 기존 북마크데이터를 저장해서 해주는 기능이 안되서 계속 gemini에게 안된다고 징징거렸더니 디버깅해보자며 하나둘 달기 시작한게 그대로 남아있다.

gemini가 이런점이 참 신기하다

    private func loadImage() -> NSImage? {
        os_log("[WidgetLog] 1. loadImage() called.", log: OSLog.default, type: .debug)
        let userDefaults = UserDefaults(suiteName: "group.desktopw.dy")
        guard let imageData = userDefaults?.data(forKey: "widgetImageData") else {
            os_log("[WidgetLog] 2. Failed: No image data found in UserDefaults.", log: OSLog.default, type: .error)
            return nil
        }
        os_log("[WidgetLog] 2. Success: Found image data (%{public}d bytes).", log: OSLog.default, type: .debug, imageData.count)
        
        if let image = NSImage(data: imageData) {
            os_log("[WidgetLog] 3. Success: Image loaded successfully from data!", log: OSLog.default, type: .debug)
            return image
        } else {
            os_log("[WidgetLog] 3. Failed: NSImage(data:) returned nil.", log: OSLog.default, type: .error)
            return nil
        }
    }
    
    func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> ()) {
        let entry = SimpleEntry(date: Date(), image: loadImage())
        // .atEnd policy tells the widget to update only when the app sends a reload signal.
        let timeline = Timeline(entries: [entry], policy: .atEnd)
        completion(timeline)
    }

 

그리고 위젯의 본문뷰 이건 딱히 기존과 크게 달라진점은 없다

struct DesktopWidgetTestWidgetEntryView: View {
    var entry: SimpleEntry

    var body: some View {
        GeometryReader { geometry in
            if let image = entry.image {
                Image(nsImage: image)
                    .resizable()
                    .scaledToFill()
                    .frame(width: geometry.size.width, height: geometry.size.height)
                    .clipped()
            } else {
                Text("Empty")
                    .frame(width: geometry.size.width, height: geometry.size.height)
                    .background(Color.gray.opacity(0.1))
                    .overlay(
                        RoundedRectangle(cornerRadius: 8)
                            .stroke(Color.gray, style: StrokeStyle(lineWidth: 1, dash: [5]))
                    )
            }
        } // GeometryReader
    }
}

 

이름을 처음에 잘지었어야 했는데.....

이번글 요약 > gemini에게 때를 썻더니 계속 뭔가를 추가해줘서 이번 글을 남길정도로 진행이 되었다 끝!

반응형

바탕화면에 귀여운 이미지를 배치해놓고 감상하는 이상한 취미를 가진사람으로서 

맥이 세콰이어(발음이 맞나모르겠다, 14버전)부터 아이폰처럼 위젯을 배치할수게된 기능을 못본척 넘어갈수없었다

만....

귀찮아서 무시하고있다가 너무 블로그 업데이트를 안한게 마음에 걸려 이번에 잡게 되었다

chat gpt로 빠르게 한시간안에 포스팅까지 끝내야지 라고 야심찬 목표를 설정하고 잡았으나 완성까지 꽤나 오래걸렸고 

왜 오래걸렸는가에 대해 포스팅을 해보려고 한다

 

1. 지피티야! 위젯 코드만들어줘!

 

나는 분명 위젯을 만들어달라고 했으나 왜인지 그냥 앱을 만드는방법을 소개해줬다....

빠른 날먹 1차 실패

 

2. 지피티야! 그건 위젯이 아니야!! widgetkit을 이용하라고!!!!

 

이번엔 그래도 꽤 괜찮게 프로젝트 만드는법을 단계적으로 설명해줬다

물론 여기저기서 써진 글들을 짜집기해서 만드는 지피티답게 완벽하진않으나 처음 학습하기에는 나쁘지않은 방법이였으니 

해당 코드도 같이 올린다.

import WidgetKit
import SwiftUI

struct SimpleEntry: TimelineEntry {
    let date: Date
}

struct DesktopWidgetTestWidgetEntryView : View {
    var entry: SimpleEntry

    var body: some View {
        ZStack {
            Color.clear // 투명 배경
            Image("sampleImage") // 프로젝트의 Assets에 넣은 이미지
                .resizable()
                .scaledToFit()
        }
    }
}

struct DesktopWidgetTestWidget: Widget {
    let kind: String = "DesktopWidgetTestWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            DesktopWidgetTestWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("Sample Image Widget")
        .description("This widget shows a sample image on your desktop.")
        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
    }
}

struct DesktopWidgetTestWidget_Previews: PreviewProvider {
    static var previews: some View {
        DesktopWidgetTestWidgetEntryView(entry: SimpleEntry(date: Date()))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

import WidgetKit
import SwiftUI

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date())
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date())
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> ()) {
        let timeline = Timeline(entries: [SimpleEntry(date: Date())], policy: .never)
        completion(timeline)
    }
}

 

이 코드들이 바로 채팅 몇글자의 요청으로 완성된 코드!

여기서 바로 끝났다면 만족하며 포스팅을 했겠지만 문제가 발생

겉과 콘텐트 사이의 패딩이 자동으로 들어가지고있었다.

이문제로 꽤나 고달파졌다.

빠른 날먹 2차 실패

 

3. 지피티야! 여백이 생기고있어! + 시뮬레이터가 동작하지 않아!

 

이제부터 슬슬 지피티가 거짓말을 하기 시작했다

자기 코드에 태클을 걸어서 그런가 지피티가 거짓말과 함께 고집을 부리기 시작하여 이제부터 스택오버플로우의 도움을 같이 받기 시작.

일단 지피티가 수정한 코드 ,  기존의 fit을 fill로 변경하고 clipped를 추가

그리고 여백제거용으로 추가한 ignoresSafeArea는 사실 최신버전에서는 동작하지 않는 기능... 이걸 몰라서 왜 그런가 싶었다

struct DesktopWidgetTestWidgetEntryView : View {
    var entry: SimpleEntry

    var body: some View {
        ZStack {
            Image("sampleImage")
                .resizable()
                .scaledToFill() // 이미지 꽉 채우기
                .ignoresSafeArea() // Safe area 무시
                .clipped() // 넘치는 부분 잘라내기
        }
        .containerBackground(.clear, for: .widget) // 위젯 배경 없애기
    }
}

참고로 시뮬레이터 이야기는 지피티가 그건 원래그래! 라며 헛소리를해서 설정을 가지고 놀다가 일단 켜지는 방법을 찾았다.

실행 스키마 부분을 아래와같이 수정했다

debug exe 부분을 체크해주고 실행이는 루트로 해주니 놀랍게도 시뮬레이터가 다시 켜졌다.

뭔가 버그가 걸렸거나 에러발생으로 권한 문제가 생긴게 아닌가 싶다 처음 실행했을때는 아래 사진과 같은 WidgetKit Simultator가 실행되었었다.

아무튼 위 처럼 스키마 수정후에는 이상하게 두번씩 열리는지 이미열려있어요! 라는 에러와 함께 시뮬레이터가 두개가 띄여진다.

위 사진은 완성후에 다시 실행한 시뮬레이터 사진

 

그렇게 몇차례 말도 안되는 이야기를 하며 존재하지않는 멤버함수들을 창작하며

그렇게 내 시간을 갉아먹고 

 

https://stackoverflow.com/questions/76877152/how-to-remove-padding-around-the-content-view-and-let-the-content-view-fill-the

위 링크의 방법으로 해결을 했다.

뷰 부분에 

.contentMarginsDisabled() // Here

라는 수정자를 달아주면된다.

 

위젯의 겉은 요렇게 지정되어있어서 이 ContentMargin부분을 비활성화 해줘야한다고함

 

그런데 이상하게도 시뮬레이터에서는 계속 마진부분이 남아잇었고....

 

나는 도대체 뭐가 문젠가 고달파지다

아이폰 시뮬레이터로 실행을 해봤었다

그리고 멀쩡히 마진이 없이 나오고...

다시 아카이브해서 앱파일로 만들고 바탕화면에 위젯을 띄여줬더니!

만족하게 꽉 채워서 나와주고있다

끝난거에대해 감사할따름...

 

그렇게 최종 코드

//
//  DesktopWidgetTestWidget.swift
//  DesktopWidgetTestWidget
//
//

import WidgetKit
import SwiftUI


struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date())
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date())
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> ()) {
        let timeline = Timeline(entries: [SimpleEntry(date: Date())], policy: .never)
        completion(timeline)
    }
}

struct SimpleEntry: TimelineEntry {
  let date: Date
}

struct DesktopWidgetTestWidgetEntryView: View {
    var entry: SimpleEntry

    var body: some View {
        GeometryReader { geometry in
            Image("sampleImage") // 프로젝트 Assets에 추가한 이미지 이름
                .resizable()
                .scaledToFill()
                .frame(width: geometry.size.width, height: geometry.size.height)
                .clipped()
        }
    }
}

struct DesktopWidgetTestWidget: Widget {
    let kind: String = "YourWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
          DesktopWidgetTestWidgetEntryView(entry: entry)
            .containerBackground(.background, for: .widget)
        }
        .configurationDisplayName("Full-bleed Image Widget")
        .description("A widget that shows an image without borders or padding.")
        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
        .disableContentMarginsIfNeeded()
    }
}

extension WidgetConfiguration {
    func disableContentMarginsIfNeeded() -> some WidgetConfiguration {
        if #available(iOSApplicationExtension 17.0, *) {
            return self.contentMarginsDisabled()
        } else {
            return self
        }
    }
}

 

 

이걸로 언제 또 포스팅을 할 맘이 들면 클릭 이벤트를 활성화해서 이미지를 변경하는거로 해볼듯하다

 

반응형

회사에서 서비스하는 앱이란건 보통 서버에서 데이터를 저장, 관리하기에 앱 단독으로 데이터 저장은 거의없었다.

개인 로그인 기록이나 편의성을 위한점때문에 userdefaults같은걸 써서 저장하거나 따로 파일을 만들어 저장하는게 편하기에 굳이 새로운 라이브러리를 넣어서 무겁게 돌릴필요가 없었다

그래도 방법은 있으니 혼자서 공부삼아 만들어보는 예제에서는 sqlite를 쓰거나 firebase를 로컬로쓰거나(듣기만했지 만들어보지도 찾아보지도 않았다.) 아니면 애플에서 제공하는 프레임워크인 Coredata를쓰거나! 한다.

그렇게 이글은 애플에서 제공하는 데이터 저장용 프레임워크인 coredata를 uikit이아닌 swiftui로 만들어 붙이는걸 공부하는 글이다.

 

 

4월달 내로 만들어서 내이름으로 처음 등록하려던 앱계획이 있었다. 하지만 정신상태가 흔들리는 일들을 계속 맛보고있는 와중이라 여러번 의욕이 꺽이면서 아직도 한참 멀었다 그앱은 자금사정상 서버를 이용한 기능은 빼고 로컬에서만 돌아가도록할건데 여기서 데이터가 영속적으로 유지해야하는 기능이 존재한다. 

데이터가 영속적으로 존재하려면 위에서 쓴 서버를 이용하거나 텍스트파일로 쓰거나, 로컬db용 라이브러리를 쓰거나해야하는데 자금문제로 서버는 무리고 텍스트파일로 쓰기에는 뭔가 공부도 안될거같아 로컬db로 목표, 그리고 이왕이면 안해본걸 잡아보고싶어서 coredata로 하기로 마음먹었다.

기억이 가물가물하지만 보통 로컬디비가 보통 시작이 이랬을거다. 

1. 디비용 클래스 초기화

2. 초기화시 디비 파일이 없다면 생성 및 스키마 뭐 그런 초기값들 설정

3. 디비 파일이 있다면 로드준비들

4. 해당 기능들을 래핑한 컨트롤러의 생성

5. 이제 컨트롤러를 이용해서 필요할때 백그라운드 스레드로 돌리면서 쿼리를 날리며 ui업데이트

이런식이였을거다.

 

프레임워크를 그냥 제공해주기에 별다른설치나 그런 고민없이 간단히 쓸수있을거라 생각하고 자료좀 찾아봤는데 뭔가 말이많다...

처음 아이폰개발 배웠을때도 이랬었나 고민하게되는 뭔가뭔가들이라 쉽게 손이 안가 그냥 튜토리얼을 따라하며 감을 잡아야겠다 싶어 따라했다

그렇게 대충 배운 내용으로는 

아래와같다

1. 프로젝트 내에 엔티티 정의용 모델파일을 생성한다 - xcdatamodeld 라는 확장자를 가진 파일

2. 정의용 모델파일에서 스키마를 설정 - 서버용의 dbms에서 gui로 편하게 만드는 엔티티라는 느낌이였다

3. 컨트롤러 생성, 컨트롤러지만 컨트롤 기능정의가 아니라 단순 모델파일 로드 - 컨테이너생성

4. enviroment modifier로 managedObjectContext 키값에 만들어둔 컨트롤러에서 컨테이너의 뷰 컨텍스트 지정 

ContentView()
	.environment(\.managedObjectContext, presistenceController.contianer.viewContext)

5. 디비 접근이 필요한곳에서 viewContext 받아주기 + 저장된 값 불러오기

@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(entity: Product.entity(), sortDescriptors: [ 값 정렬 ]
private var products: FetchedResults<Product>

뭐 그렇게 읽고

쓸때는 저기 Product라고 만들어준 엔티티를 생성해줘서 위에서 받아온 viewContext를 이용해 save() 메소드를 호출하면된다.

  private func addProduct() {
    withAnimation {
      let product = Product(context: viewContext)
      product.name = name
      product.quantity = quantity
      
      saveContext()
    }
  } // addProduct
  
  private func saveContext() {
    do {
      try viewContext.save()
    } catch {
      let error = error as NSError
      fatalError("An error occurred: \(error)")
    }
  }// saveContext

참고로 여기서 Product 타입은 엔티티 만드는곳에서 자동으로 생성해서 쓰고있다

이걸 따로 수동으로 만들어서 해줄수도 있음.

수동으로하려면 엔티티 생성해주는곳에서 메뉴얼을 선택해줘야한다. 아래 사진의 Codegen부분이다 Manual과 CategoryExtension이라는게 있던데 카테고리 뭐시기는 아직 안해봤다.

 

위에서 데이터 Fetch해줄때 그냥 전부 가져왔는데 조건문을 넣어서 불러오는것도 당연히 있다.

예제에서는 task 수정자를 이용해 하도록 안내해줬다

.task {
        let fetchRequest: NSFetchRequest<Product> = Product.fetchRequest()
        fetchRequest.entity = Product.entity()
        fetchRequest.predicate = NSPredicate(format: "name CONTAINS %@", name)
        matches = try? viewContext.fetch(fetchRequest)
      }

contain연산자를 이용한 name 검색인데 별건 없다

자동으로 만들어준 Product라 그런가 별에 별게 다 미리 지정되어있다 fetchRequest라던가...

사실 이건 내부 들어가보면 별거 없긴한데 자동이라 편하다.

    @nonobjc public class func fetchRequest() -> NSFetchRequest<Product> {
        return NSFetchRequest<Product>(entityName: "Product")
    }

 

그리고 마지막으로 crud에서 update는 안다룰거고... delete는 뭔가 해준 느낌이 안난다.

      offsets.map { products[$0] }.forEach(viewContext.delete)
      saveContext()

값을 list로 뿌려줬으니 offset값을 바로 가져올수있고 받아온 offset값에 대응하는 products값에서 delete메소드를 불러준후 save 메소드를 불러주는거로 끝이난다

 

기존 로컬디비 다루는법에서 다른점은 

컨트롤러에서의 해주는게 사용하려는 클래스? 스트럭트? (swiftui는 죄다 구조체로 선언해서 용어적으로 뭘 불러야할지 모르겠다 )

사용하려는곳에서 프로퍼티래퍼를 달아줘서 기능을 사용한다는 점일까?

 

일단 이론적인 이야기로는

앱 -

영구컨테이너 -

관리 객체 콘텍스트 ~ 관리 객체 ~ 관리 객체 모델 ~ 엔티티 디스크립션 -

영구 저장소 코디네이터

영구 저장소 객체

데이터베이스

이런 레이어들이 쌓여서 서로 부르고 알고 모르니 하는 설명이 뭔가 많다.

예제에서 다루는 부분을 보면 우선 코디네이터 아래단은 전부 프레임워크에 역할을 맡기는거 같고

영구 컨테이너는 컨트롤러에서 생성, 그 생성할 관리 객체단이 xcdatamodeld파일이라는 느낌? 

반응형

https://www.pcmag.com/news/apples-app-store-rules-have-to-change-after-latest-epic-games-ruling

챗 gpt 요약

📌 주요 요약: Apple, 법원 명령 위반으로 제재
법원 판결: 미국 연방 판사 이본 곤잘레스 로저스(Yvonne Gonzalez Rogers)는 Apple이 2021년 Epic Games와의 반독점 소송에서 내려진 명령을 고의로 위반했다고 판결했습니다. ​
CCN.com
+7
AP News
+7
The Verge
+7

명령 내용: 해당 명령은 Apple이 개발자들에게 외부 결제 수단에 대한 정보를 사용자에게 제공할 수 있도록 허용하고, 앱 외부에서 이루어지는 구매에 대해 30%의 수수료를 부과하지 않도록 요구했습니다.​

Apple의 대응: Apple은 2024년부터 일부 앱 외부 구매에 대해 27%의 수수료를 부과하고, 외부 결제 수단에 대한 사용자 안내를 제한하는 정책을 도입했습니다. ​
MarketWatch

법원의 비판: 로저스 판사는 Apple의 이러한 조치를 명령에 대한 "의도적인 위반"으로 간주하고, Apple의 재무 부사장 알렉스 로만(Alex Roman)이 법정에서 위증했다고 지적했습니다. ​
CCN.com
+2
Business Insider
+2
MarketWatch
+2

추가 조치: 법원은 Apple의 위반 행위를 즉각 중단할 것을 명령하고, 형사 모독죄 가능성을 검토하기 위해 사건을 미국 연방 검찰에 회부했습니다. ​

Epic Games의 반응: Epic Games는 이번 판결을 환영하며, 미국 iOS 앱 스토어에 Fortnite를 다시 출시할 계획을 발표했습니다. ​
Vulture

이러한 판결은 Apple의 앱 스토어 정책에 중대한 변화를 요구하며, 개발자들에게 더 많은 자유를 제공할 수 있는 계기가 될 것으로 보입니다. 추가로 궁금하신 점이나 더 자세한 내용이 필요하시면 언제든지 말씀해 주세요.

 

 

---

주요 내용은 에픽의게임이 결제를 애플에서 제공하는 수수료 30%짜리를 이용하지 않아 게임이 내려간건에 대한 소송이였고

에픽이 이겼습니다

이 일로 ios앱에 기존에는 허용하지 않았던 외부결제 - 웹을 통한 결제또는 또다른 결제 방식이 허용된다면 비싸게 제공하던 수수료를 상당히 많이 아낄수 있게됩니다.

이게 미국에 한정한 일인지 모든 지역에 한정된 일인지는 조금더 지켜봐야알것같습니다

만약 모든지역이라면 애플 자사 결제를 좀 더 싸고 간편히 제공하도록 하지 않을까 싶습니다

기존 방식이 정말 이상해보이는점이 많았기에 고생이 많았습니다만 여러 결제 제공사와 경쟁으로 좋아지길 간절히 바라고있습니다

 

반응형

Struct 'ViewBuilder' requires that 'EmptyTableRowContent<V>' conform to 'View'

Struct 'ViewBuilder' requires that 'TableHeaderRowContent<V, Text>' conform to 'View'

Static method 'buildExpression' requires that 'HStack<TupleView<(Text, Spacer, Text)>>' conform to 'TableRowContent'

 

모델을 좀 통폐합 시키다보니 갑자기 뷰쪽에서 이러한 에러들이 떳다

처음엔 단순히 실수로 괄호가 지워졌나 싶었는데 아무리봐도 틀린부분이 없었다

검색해보니 바인딩에러니 어쩌니 하길래 @붙은 부분을 전부 확인해봐도 모르겠다....

 

그렇게 이것저것 검색하고 안되고, 세번째 에러 부분을 검색하다가

스택오버플로우의 한 댓글을 발견

You seems to do some strange additions in your Text : not sure dayOfWeek is a string. To find out where the problem is : comment your HStack in the ForEach and add them one by one to find the falty one. The compiler gives sometimes error on ForEach when something is wrong in the code inside it. 

 CommentedApr 6, 2024 at 9:47

 

Text부분의 이상한 확장이 있는거 같다는글에 관련된 뷰에 있는 텍스트 코드들을 전부 주석처리하니 에러가 사라짐을 발견

내가 모델을 수정하면서 date 변수를 created와 deadLine으로 분리해놓은걸 깜빡했다.

왜 date변수가 없어요! 라는 에러가 안뜨고 이상한 에러가 떳는지 모르겠다.

preview.date 부분을 멀쩡한걸로 변경해주니 다시 돌아간다....

 

이번달안으로 끝내기로 마음먹은 프로젝트가 진도가 안나간다

반응형

+ Recent posts