🏝️

SwiftUIでDynamic Island風UIを作ってみる

に公開

こんにちは、K@zuki.です。
ラビー合同会社の方では、委託を受けているものはWebサイト・サービスが主ですが、自社開発だとiOS/macOS系が多いです。
今回はその中でNotchNookというアプリにインスパイアを受け、iPhoneのDynamic Island風なアニメーションUIを実装してみます。
実際に作ってみたものはこちら。

今回作るもの

今回作成するものの要点としては、以下のような特徴をもたせてDynamic Island風なUIとします。

  • 画面右上角に現れるDynamic Island風なUI
  • カーソルが近付くと正方形が出現 → ホバーで拡張の2段階アニメーション
  • iPhone Dynamic Islandと同じようなスムーズな変形

普段はクラムシェルモードで開発していることが多いので、動作確認しやすくするために右上の角でアニメーションを動作させるようにしてみます。

基本的なアプリ構成

まず、メニューバーアプリとしてSwiftUIとAppKitを組み合わせた構成を作ります。
ポイントはObservableObjectを使って、SwiftUIビューから状態を監視できるようにしていることです。

import AppKit
import SwiftUI

@main
struct DynamicIslandApp: App {
  @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

  var body: some Scene {
    Settings {
      EmptyView()
    }
  }
}

final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
  private let pillSize: CGFloat = 48 // 正方形初期サイズ
  
  private var statusItem: NSStatusItem?
  private var cornerWindow: NSWindow?
  private var mouseMonitor: Any?
  
  @Published var isWindowVisible = false
  @Published var currentExpandedState = false

  func applicationDidFinishLaunching(_ notification: Notification) {
    setupStatusItem()
    setupCornerWindow()
    startMouseMonitoring()
  }
}

正方形のWindowの作成

画面右上角に配置される正方形のWindowを作成するために関数を定義していきます。
初期表示時には、48x48の正方形を表示ししておきます。
またlevel = .statusBarとしておくことで、メニューバーの下に表示されないように調整します。

// AppDelegate
private func setupCornerWindow() {
  guard let screen = NSScreen.main else { return }
  let screenFrame = screen.frame

  // 初期表示の正方形を設定
  let windowSize = CGSize(width: pillSize, height: pillSize)
  let origin = CGPoint(
    x: screenFrame.maxX - windowSize.width,
    y: screenFrame.maxY - windowSize.height
  )

  cornerWindow = NSWindow(
    contentRect: NSRect(origin: origin, size: windowSize),
    styleMask: [.borderless],
    backing: .buffered,
    defer: false
  )

  if let window = cornerWindow {
    window.isOpaque = false
    window.backgroundColor = .clear
    window.level = .statusBar // .floatngなどだとメニューバーより下に強制的に配置されれる
    window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
    window.ignoresMouseEvents = false

    let cornerView = CornerPillView(size: pillSize, appDelegate: self)
    window.contentView = NSHostingView(rootView: cornerView)

    window.alphaValue = 1.0
  }
}

マウス近接検知

グローバルマウス監視で、マウスが右上角に近づいたらWindowを表示します。
表示と非表示の閾値を変えることで、境界付近での点滅を防いでいます。
また、初期表示のshowThresholdと表示後の閉じるためのhideThresholdは動的に変更することで、体験としてスムーズなアニメーションが期待できます。

private func startMouseMonitoring() {
  mouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .mouseMoved) { [weak self] event in
    self?.handleMouseMove(at: event.locationInWindow)
  }
}

private func handleMouseMove(at location: CGPoint) {
  guard let screen = NSScreen.main else { return }

  let mouseLocation = NSEvent.mouseLocation
  let cornerPoint = CGPoint(x: screen.frame.maxX, y: screen.frame.maxY)
  let distance = sqrt(
    pow(mouseLocation.x - cornerPoint.x, 2) + pow(mouseLocation.y - cornerPoint.y, 2))

  updateWindowVisibility(for: distance)
}

private func updateWindowVisibility(for distance: CGFloat) {
  guard let window = cornerWindow else { return }

  let windowSize = max(window.frame.width, window.frame.height)
  let showThreshold: CGFloat = 120
  let hideThreshold = (windowSize * 1.5) + 24

  if distance < showThreshold {
    if !isWindowVisible {
      window.orderFront(nil)
      isWindowVisible = true
    }
  } else if distance > hideThreshold {
    if isWindowVisible {
      isWindowVisible = false
      DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
        window.orderOut(nil)
      }
    }
  }
}

SwiftUIビューの実装

Dynamic Island風のビューを実装します。
状態に応じて「Pill」と「Extended」を切り替えて視覚的に分かりやすくします。

struct CornerPillView: View {
  let size: CGFloat
  @ObservedObject var appDelegate: AppDelegate
  @State private var isHovered = false

  var body: some View {
    contentView
      .frame(maxWidth: .infinity, maxHeight: .infinity)
      .scaleEffect(appDelegate.isWindowVisible ? 1.0 : 0.1, anchor: .topTrailing)
      .animation(.spring(response: 0.3, dampingFraction: 0.6), value: appDelegate.isWindowVisible)
      .onHover { hovering in
        isHovered = hovering
        appDelegate.updateWindowSize(expanded: hovering)
      }
  }
  
  @ViewBuilder
  private var contentView: some View {
    if appDelegate.currentExpandedState {
      ExpandedView()
    } else {
      PillView()
    }
  }
}

struct PillView: View {
  var body: some View {
    RoundedRectangle(cornerRadius: 8)
      .fill(Color(NSColor.controlBackgroundColor))
      .overlay(
        Text("Pill")
          .font(.caption)
          .foregroundColor(.secondary)
      )
  }
}

struct ExpandedView: View {
  var body: some View {
    RoundedRectangle(cornerRadius: 8)
      .fill(Color(NSColor.controlBackgroundColor))
      .overlay(
        Text("Extended")
          .font(.caption)
          .foregroundColor(.secondary)
      )
  }
}

動的Windowリサイズ

ホバー時にWindowサイズを動的に変更します。
Viewのサイズを変更するやり方もありますが、中々うまくいかなかったの
でWindowのリサイズ切り替えました。
@Publishedで状態管理を行い、SwiftUIビューが自動的に更新されることで、Viewの切り替えを実現しています。

// AppDelegate
func updateWindowSize(expanded: Bool) {
  guard expanded != currentExpandedState,
    !isAnimating,
    let window = cornerWindow,
    let screen = NSScreen.main
  else { return }

  currentExpandedState = expanded
  isAnimating = true

  let newWidth = expanded ? pillSize * 3 : pillSize
  let newHeight = expanded ? pillSize * 5 : pillSize
  let newFrame = NSRect(
    x: screen.frame.maxX - newWidth,
    y: screen.frame.maxY - newHeight,
    width: newWidth,
    height: newHeight
  )

  NSAnimationContext.runAnimationGroup { context in
    context.duration = 0.3
    context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
    window.animator().setFrame(newFrame, display: true, animate: true)
  } completionHandler: { [weak self] in
    self?.isAnimating = false
  }
}

概ねこのような形で実装しつつ、アニメーションなどをつめていけばでDynamic Island風なUIを実装することができます。

まとめ

SwiftUIとAppKitを組み合わせることで、iPhone Dynamic Island風のUIをmacOSで実現できました。
今回実装したUIを使って自社のアプリ開発で取り入れる可能性はあるかもしれません。
特にVigilareや移管予定のChimrに取り入れる可能性も検討していますし、まだ発表していないLauncherのようなアプリも追加するかもしれません。

もし実装したい人がいたり、すでに便利なアプリで導入したいと考えてる人がいれば是非参考にしてみてください。

https://apps.apple.com/jp/app/vigilare-enhanced-reminders/id6748925073?l=en-US&mt=12

https://chimr.zuki.dev/

Discussion