瀏覽代碼

toolbar(iOS): use Liquid Glass buttons for iOS 26

osy 1 月之前
父節點
當前提交
a0ce24ab8e
共有 3 個文件被更改,包括 175 次插入104 次删除
  1. 11 0
      Platform/iOS/VMToolbarDisplayMenuView.swift
  2. 161 101
      Platform/iOS/VMToolbarView.swift
  3. 3 3
      UTM.xcodeproj/project.pbxproj

+ 11 - 0
Platform/iOS/VMToolbarDisplayMenuView.swift

@@ -102,3 +102,14 @@ private struct Badge: View {
         }
     }
 }
+
+private extension View {
+    @ViewBuilder
+    func customBadge(_ count: Int) -> some View {
+        if #available(iOS 15, *) {
+            self.badge(count)
+        } else {
+            self.overlay(Badge(count: count), alignment: .topTrailing)
+        }
+    }
+}

+ 161 - 101
Platform/iOS/VMToolbarView.swift

@@ -23,7 +23,7 @@ struct VMToolbarView: View {
     @State private var shake: Bool = true
     @State private var isMoving: Bool = false
     @State private var isIdle: Bool = false
-    @State private var dragPosition: CGPoint = .zero
+    @State private var dragOffset: CGSize = .zero
     @State private var shortIdleTask: DispatchWorkItem?
     
     @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@@ -33,20 +33,20 @@ struct VMToolbarView: View {
     
     @Binding var state: VMWindowState
     
+    @Namespace private var namespace
+    
     private var spacing: CGFloat {
-        let direction: CGFloat
-        let distance: CGFloat
-        if location == .topLeft || location == .bottomLeft {
-            direction = -1
+        let add: CGFloat
+        if #available(iOS 26, *) {
+            add = 0
         } else {
-            direction = 1
+            add = 8
         }
         if horizontalSizeClass == .compact || verticalSizeClass == .compact {
-            distance = 40
+            return add + 0
         } else {
-            distance = 56
+            return add + 8
         }
-        return direction * distance
     }
     
     private var nameOfHideIcon: String {
@@ -80,63 +80,71 @@ struct VMToolbarView: View {
     }
     
     var body: some View {
-        GeometryReader { geometry in
-            Group {
-                Button {
-                    if state.isRunning {
-                        state.alert = .powerDown
-                    } else {
-                        state.alert = .terminateApp
-                    }
-                } label: {
-                    if state.isRunning {
-                        Label("Power Off", systemImage: "power")
-                    } else {
-                        Label("Force Kill", systemImage: "xmark")
-                    }
-                }.offset(offset(for: 8))
-                Button {
-                    session.pauseResume()
-                } label: {
-                    Label(state.isRunning ? "Pause" : "Play", systemImage: state.isRunning ? "pause" : "play")
-                }.offset(offset(for: 7))
-                Button {
-                    state.alert = .restart
-                } label: {
-                    Label("Restart", systemImage: "restart")
-                }.offset(offset(for: 6))
-                Button {
-                    if case .serial(_, _) = state.device {
-                        let template = session.qemuConfig.serials[state.device!.configIndex].terminal?.resizeCommand
-                        state.toggleDisplayResize(command: template)
-                    } else {
-                        state.toggleDisplayResize()
+        if #available(iOS 26, *) {
+            GlassEffectContainer(spacing: spacing) {
+                toolbarBody
+            }
+        } else {
+            toolbarBody
+        }
+    }
+    
+    @ViewBuilder
+    var toolbarBody: some View {
+        toolbarContainer { geometry in
+            if !isCollapsed {
+                Group {
+                    Button {
+                        if state.isRunning {
+                            state.alert = .powerDown
+                        } else {
+                            state.alert = .terminateApp
+                        }
+                    } label: {
+                        if state.isRunning {
+                            Label("Power Off", systemImage: "power")
+                        } else {
+                            Label("Force Kill", systemImage: "xmark")
+                        }
+                    }.animationUniqueID("power", in: namespace)
+                    Button {
+                        session.pauseResume()
+                    } label: {
+                        Label(state.isRunning ? "Pause" : "Play", systemImage: state.isRunning ? "pause" : "play")
+                    }.animationUniqueID("pause", in: namespace)
+                    Button {
+                        state.alert = .restart
+                    } label: {
+                        Label("Restart", systemImage: "restart")
+                    }.animationUniqueID("restart", in: namespace)
+                    Button {
+                        if case .serial(_, _) = state.device {
+                            let template = session.qemuConfig.serials[state.device!.configIndex].terminal?.resizeCommand
+                            state.toggleDisplayResize(command: template)
+                        } else {
+                            state.toggleDisplayResize()
+                        }
+                    } label: {
+                        Label("Zoom", systemImage: state.isViewportChanged ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")
+                    }.animationUniqueID("resize", in: namespace)
+                    #if WITH_USB
+                    if session.vm.hasUsbRedirection {
+                        VMToolbarUSBMenuView()
+                            .animationUniqueID("usb", in: namespace)
                     }
-                } label: {
-                    Label("Zoom", systemImage: state.isViewportChanged ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")
-                }.offset(offset(for: 5))
-                #if WITH_USB
-                if session.vm.hasUsbRedirection {
-                    VMToolbarUSBMenuView()
-                    .offset(offset(for: 4))
-                }
-                #endif
-                VMToolbarDriveMenuView(config: session.qemuConfig)
-                .offset(offset(for: 3))
-                VMToolbarDisplayMenuView(state: $state)
-                .offset(offset(for: 2))
-                Button {
-                    state.isKeyboardRequested = !state.isKeyboardShown
-                } label: {
-                    Label("Keyboard", systemImage: "keyboard")
-                }.offset(offset(for: 1))
-            }.buttonStyle(.toolbar(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass))
-            .menuStyle(.toolbar)
-            .disabled(state.isBusy)
-            .opacity(isCollapsed ? 0 : 1)
-            .position(position(for: geometry))
-            .transition(.slide)
-            .animation(.default)
+                    #endif
+                    VMToolbarDriveMenuView(config: session.qemuConfig)
+                        .animationUniqueID("drive", in: namespace)
+                    VMToolbarDisplayMenuView(state: $state)
+                        .animationUniqueID("display", in: namespace)
+                    Button {
+                        state.isKeyboardRequested = !state.isKeyboardShown
+                    } label: {
+                        Label("Keyboard", systemImage: "keyboard")
+                    }.animationUniqueID("keyboard", in: namespace)
+                }.toolbarButtonStyle(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass)
+                .disabled(state.isBusy)
+            }
             Button {
                 resetIdle()
                 longIdleTimeout.assertUserInteraction()
@@ -145,25 +153,26 @@ struct VMToolbarView: View {
                 }
             } label: {
                 Label("Hide", systemImage: isCollapsed ? nameOfHideIcon : nameOfShowIcon)
-            }.buttonStyle(.toolbar(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass))
+            }.toolbarButtonStyle(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass)
+            .animationUniqueID("hide", in: namespace)
             .modifier(HideToolbarTipModifier(isCollapsed: $isCollapsed))
             .opacity(toolbarToggleOpacity)
             .modifier(Shake(shake: shake))
-            .position(position(for: geometry))
+            .offset(dragOffset)
             .highPriorityGesture(
-                DragGesture()
+                DragGesture(coordinateSpace: .named("Window"))
                     .onChanged { value in
                         withOptionalAnimation {
                             isCollapsed = true
+                            isMoving = true
+                            dragOffset = value.translation
                         }
-                        isMoving = true
-                        dragPosition = value.location
                     }
                     .onEnded { value in
                         withOptionalAnimation {
                             location = closestLocation(to: value.location, for: geometry)
                             isMoving = false
-                            dragPosition = position(for: geometry)
+                            dragOffset = .zero
                         }
                         resetIdle()
                         longIdleTimeout.assertUserInteraction()
@@ -185,6 +194,46 @@ struct VMToolbarView: View {
         }
     }
     
+    @ViewBuilder
+    private func toolbarContainer<Content: View>(@ViewBuilder body: @escaping (GeometryProxy) -> Content) -> some View {
+        GeometryReader { geometry in
+            switch location {
+            case .topRight:
+                VStack(alignment: .trailing) {
+                    HStack(alignment: .top, spacing: spacing) {
+                        Spacer()
+                        body(geometry)
+                    }.padding(.trailing)
+                    Spacer()
+                }.padding(.top)
+            case .bottomRight:
+                VStack(alignment: .trailing) {
+                    Spacer()
+                    HStack(alignment: .bottom, spacing: spacing) {
+                        Spacer()
+                        body(geometry)
+                    }.padding(.trailing)
+                }.padding(.bottom)
+            case .topLeft:
+                VStack(alignment: .leading) {
+                    HStack(alignment: .top, spacing: spacing) {
+                        body(geometry)
+                        Spacer()
+                    }.padding(.leading)
+                    Spacer()
+                }.padding(.top)
+            case .bottomLeft:
+                VStack(alignment: .leading) {
+                    Spacer()
+                    HStack(alignment: .bottom, spacing: spacing) {
+                        body(geometry)
+                        Spacer()
+                    }.padding(.leading)
+                }.padding(.bottom)
+            }
+        }.coordinateSpace(name: "Window")
+    }
+    
     private func withOptionalAnimation<Result>(_ animation: Animation? = .default, _ body: () throws -> Result) rethrows -> Result {
         if UIAccessibility.isReduceMotionEnabled {
             return try body()
@@ -193,27 +242,6 @@ struct VMToolbarView: View {
         }
     }
     
-    private func position(for geometry: GeometryProxy) -> CGPoint {
-        let yoffset: CGFloat = 48
-        var xoffset: CGFloat = 48
-        guard !isMoving else {
-            return dragPosition
-        }
-        if session.vm.hasUsbRedirection && !isCollapsed {
-            xoffset -= 12
-        }
-        switch location {
-        case .topRight:
-            return CGPoint(x: geometry.size.width - xoffset, y: yoffset)
-        case .bottomRight:
-            return CGPoint(x: geometry.size.width - xoffset, y: geometry.size.height - yoffset)
-        case .topLeft:
-            return CGPoint(x: xoffset, y: yoffset)
-        case .bottomLeft:
-            return CGPoint(x: xoffset, y: geometry.size.height - yoffset)
-        }
-    }
-    
     private func closestLocation(to point: CGPoint, for geometry: GeometryProxy) -> ToolbarLocation {
         if point.x < geometry.size.width/2 && point.y < geometry.size.height/2 {
             return .topLeft
@@ -226,15 +254,6 @@ struct VMToolbarView: View {
         }
     }
     
-    private func offset(for index: Int) -> CGSize {
-        var sub = 0
-        if !session.vm.hasUsbRedirection && index >= 4 {
-            sub = 1
-        }
-        let x = isCollapsed ? 0 : -CGFloat(index-sub)*spacing
-        return CGSize(width: x, height: 0)
-    }
-    
     private func resetIdle() {
         if let task = shortIdleTask {
             task.cancel()
@@ -328,6 +347,47 @@ struct ToolbarMenuStyle: MenuStyle, ToolbarButtonBaseStyle {
     }
 }
 
+private extension View {
+    @ViewBuilder
+    func toolbarButtonStyle(horizontalSizeClass: UserInterfaceSizeClass? = nil, verticalSizeClass: UserInterfaceSizeClass? = nil) -> some View {
+        if #available(iOS 26, *) {
+            self
+                .menuStyle(.button)
+                .buttonStyle(.glass)
+                .buttonBorderShape(.circle)
+                .labelStyle(.iconOnly)
+                .tint(.white)
+                .controlSize(forHorizontalSizeClass: horizontalSizeClass)
+        } else {
+            self
+                .buttonStyle(.toolbar(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass))
+                .menuStyle(.toolbar)
+        }
+    }
+    
+    @ViewBuilder
+    func animationUniqueID(_ id: (some Hashable & Sendable)?, in namespace: Namespace.ID) -> some View {
+        if #available(iOS 26, *) {
+            self
+                .glassEffectID(id, in: namespace)
+                .matchedGeometryEffect(id: id, in: namespace)
+        } else {
+            self
+                .matchedGeometryEffect(id: id, in: namespace)
+        }
+    }
+    
+    @available(iOS 15, *)
+    @ViewBuilder
+    func controlSize(forHorizontalSizeClass horizontalSizeClass: UserInterfaceSizeClass?) -> some View {
+        if horizontalSizeClass == .regular {
+            self.controlSize(.large)
+        } else {
+            self
+        }
+    }
+}
+
 // https://www.objc.io/blog/2019/10/01/swiftui-shake-animation/
 struct Shake: GeometryEffect {
     var amount: CGFloat = 8

+ 3 - 3
UTM.xcodeproj/project.pbxproj

@@ -241,8 +241,8 @@
 		84C584E3268F8AE7000FCABF /* VMQEMUSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C584E2268F8AE7000FCABF /* VMQEMUSettingsView.swift */; };
 		84C584E5268F8C65000FCABF /* VMAppleSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C584E4268F8C65000FCABF /* VMAppleSettingsView.swift */; };
 		84C584EB268FA6D1000FCABF /* VMConfigAppleSystemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C584EA268FA6D1000FCABF /* VMConfigAppleSystemView.swift */; };
-		84C60FB72681A41B00B58C00 /* VMToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FB62681A41B00B58C00 /* VMToolbarView.swift */; };
-		84C60FB82681A41B00B58C00 /* VMToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FB62681A41B00B58C00 /* VMToolbarView.swift */; };
+		84C60FB72681A41B00B58C00 /* VMToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FB62681A41B00B58C00 /* VMToolbarView.swift */; platformFilter = ios; };
+		84C60FB82681A41B00B58C00 /* VMToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FB62681A41B00B58C00 /* VMToolbarView.swift */; platformFilter = ios; };
 		84C60FBA268269D700B58C00 /* VMDisplayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FB9268269D700B58C00 /* VMDisplayViewController.swift */; };
 		84C60FBB268269D700B58C00 /* VMDisplayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FB9268269D700B58C00 /* VMDisplayViewController.swift */; };
 		84CE3DAC2904C14100FF068B /* InAppSettingsKit in Frameworks */ = {isa = PBXBuildFile; platformFilter = ios; productRef = 84CE3DAB2904C14100FF068B /* InAppSettingsKit */; };
@@ -1047,7 +1047,7 @@
 		CEF7F5E12AEEDCC400E34952 /* VMConfigSharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954724AD4F980059923A /* VMConfigSharingView.swift */; };
 		CEF7F5E22AEEDCC400E34952 /* VMConfigInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954824AD4F980059923A /* VMConfigInputView.swift */; };
 		CEF7F5E32AEEDCC400E34952 /* VMWizardOSOtherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF0305426A2AFDD00667B63 /* VMWizardOSOtherView.swift */; };
-		CEF7F5E42AEEDCC400E34952 /* VMToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FB62681A41B00B58C00 /* VMToolbarView.swift */; };
+		CEF7F5E42AEEDCC400E34952 /* VMToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C60FB62681A41B00B58C00 /* VMToolbarView.swift */; platformFilter = ios; };
 		CEF7F5E52AEEDCC400E34952 /* VMDisplayMetalViewController+Gamepad.m in Sources */ = {isa = PBXBuildFile; fileRef = 5286EC8F2437488E007E6CBC /* VMDisplayMetalViewController+Gamepad.m */; };
 		CEF7F5E62AEEDCC400E34952 /* VMWizardHardwareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF0307326A2B40B00667B63 /* VMWizardHardwareView.swift */; };
 		CEF7F5E72AEEDCC400E34952 /* UTMRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841E997428AA1191003C6CB6 /* UTMRegistry.swift */; };