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のようなアプリも追加するかもしれません。
もし実装したい人がいたり、すでに便利なアプリで導入したいと考えてる人がいれば是非参考にしてみてください。
Discussion