123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500 |
- //
- // Copyright © 2021 osy. All rights reserved.
- //
- // Licensed under the Apache License, Version 2.0 (the "License");
- // you may not use this file except in compliance with the License.
- // You may obtain a copy of the License at
- //
- // http://www.apache.org/licenses/LICENSE-2.0
- //
- // Unless required by applicable law or agreed to in writing, software
- // distributed under the License is distributed on an "AS IS" BASIS,
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- // See the License for the specific language governing permissions and
- // limitations under the License.
- //
- import SwiftUI
- import TipKit
- struct VMToolbarView: View {
- @AppStorage("ToolbarIsCollapsed") private var isCollapsed: Bool = false
- @AppStorage("ToolbarLocation") private var location: ToolbarLocation = .topRight
- @State private var shake: Bool = true
- @State private var isMoving: Bool = false
- @State private var isIdle: Bool = false
- @State private var dragOffset: CGSize = .zero
- @State private var shortIdleTask: DispatchWorkItem?
- @State private var isKeyShortcutsShown: Bool = false
-
- @Environment(\.horizontalSizeClass) private var horizontalSizeClass
- @Environment(\.verticalSizeClass) private var verticalSizeClass
- @EnvironmentObject private var session: VMSessionState
- @StateObject private var longIdleTimeout = LongIdleTimeout()
-
- @Binding var state: VMWindowState
-
- @Namespace private var namespace
-
- private var spacing: CGFloat {
- let add: CGFloat
- if #available(iOS 26, *) {
- add = 0
- } else {
- add = 8
- }
- if horizontalSizeClass == .compact || verticalSizeClass == .compact {
- return add + 0
- } else {
- return add + 8
- }
- }
-
- private var nameOfHideIcon: String {
- if location == .topLeft || location == .bottomLeft {
- return "chevron.right"
- } else {
- return "chevron.left"
- }
- }
-
- private var nameOfShowIcon: String {
- if location == .topLeft || location == .bottomLeft {
- return "chevron.left"
- } else {
- return "chevron.right"
- }
- }
-
- private var toolbarToggleOpacity: Double {
- if state.device != nil && !state.isBusy && state.isRunning && isCollapsed && !isMoving {
- if !longIdleTimeout.isUserInteracting {
- return 0
- } else if isIdle {
- return 0.4
- } else {
- return 1
- }
- } else {
- return 1
- }
- }
-
- var body: some View {
- 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)
- }
- #endif
- VMToolbarDriveMenuView(config: session.qemuConfig)
- .animationUniqueID("drive", in: namespace)
- VMToolbarDisplayMenuView(state: $state)
- .animationUniqueID("display", in: namespace)
- Button {
- // ignore if we are showing shortcuts
- guard !isKeyShortcutsShown else {
- return
- }
- state.isKeyboardRequested = !state.isKeyboardShown
- } label: {
- Label("Keyboard", systemImage: "keyboard")
- }.animationUniqueID("keyboard", in: namespace)
- #if !WITH_REMOTE
- .simultaneousGesture(
- LongPressGesture().onEnded { _ in
- isKeyShortcutsShown.toggle()
- }
- )
- .sheet(isPresented: $isKeyShortcutsShown) {
- VMKeyboardShortcutsView { keys in
- session.sendKeys(keys: keys)
- }
- }
- #endif
- }.toolbarButtonStyle(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass)
- .disabled(state.isBusy)
- }
- Button {
- resetIdle()
- longIdleTimeout.assertUserInteraction()
- withOptionalAnimation {
- isCollapsed.toggle()
- }
- } label: {
- Label("Hide", systemImage: isCollapsed ? nameOfHideIcon : nameOfShowIcon)
- }.toolbarButtonStyle(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass)
- .animationUniqueID("hide", in: namespace)
- .modifier(HideToolbarTipModifier(isCollapsed: $isCollapsed))
- .opacity(toolbarToggleOpacity)
- .modifier(Shake(shake: shake))
- .offset(dragOffset)
- .highPriorityGesture(
- DragGesture(coordinateSpace: .named("Window"))
- .onChanged { value in
- withOptionalAnimation {
- isCollapsed = true
- isMoving = true
- dragOffset = value.translation
- }
- }
- .onEnded { value in
- withOptionalAnimation {
- location = closestLocation(to: value.location, for: geometry)
- isMoving = false
- dragOffset = .zero
- }
- resetIdle()
- longIdleTimeout.assertUserInteraction()
- }
- )
- .onAppear {
- resetIdle()
- longIdleTimeout.assertUserInteraction()
- if isCollapsed {
- withOptionalAnimation(.easeInOut(duration: 1)) {
- shake.toggle()
- }
- }
- }
- .onChange(of: state.isUserInteracting) { newValue in
- longIdleTimeout.assertUserInteraction()
- session.activeWindow = state.id
- }
- }
- }
-
- @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()
- } else {
- return try withAnimation(animation, body)
- }
- }
-
- 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
- } else if point.x < geometry.size.width/2 && point.y > geometry.size.height/2 {
- return .bottomLeft
- } else if point.x > geometry.size.width/2 && point.y > geometry.size.height/2 {
- return .bottomRight
- } else {
- return .topRight
- }
- }
-
- private func resetIdle() {
- if let task = shortIdleTask {
- task.cancel()
- }
- self.isIdle = false
- shortIdleTask = DispatchWorkItem {
- self.shortIdleTask = nil
- withOptionalAnimation {
- self.isIdle = true
- }
- }
- DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: shortIdleTask!)
- }
- }
- enum ToolbarLocation: Int {
- case topRight
- case bottomRight
- case topLeft
- case bottomLeft
- }
- protocol ToolbarButtonBaseStyle<Label, Content> {
- associatedtype Label: View
- associatedtype Content: View
-
- var horizontalSizeClass: UserInterfaceSizeClass? { get }
- var verticalSizeClass: UserInterfaceSizeClass? { get }
-
- func makeBodyBase(label: Label, isPressed: Bool) -> Content
- }
- extension ToolbarButtonBaseStyle {
- private var size: CGFloat {
- (horizontalSizeClass == .compact || verticalSizeClass == .compact) ? 32 : 48
- }
-
- func makeBodyBase(label: Label, isPressed: Bool) -> some View {
- ZStack {
- Circle()
- .foregroundColor(.gray)
- .opacity(isPressed ? 0.8 : 0.7)
- .blur(radius: 0.1)
- label
- .labelStyle(.iconOnly)
- .foregroundColor(isPressed ? .secondary : .white)
- .opacity(0.75)
- }.frame(width: size, height: size)
- .mask(Circle().frame(width: size-2, height: size-2))
- .scaleEffect(isPressed ? 1.2 : 1)
- .hoverEffect(.lift)
- }
- }
- struct ToolbarButtonStyle: ButtonStyle, ToolbarButtonBaseStyle {
- typealias Label = Configuration.Label
-
- @Environment(\.horizontalSizeClass) private var horizontalSizeClassEnvironment
- @Environment(\.verticalSizeClass) private var verticalSizeClassEnvironment
-
- var horizontalSizeClass: UserInterfaceSizeClass?
- var verticalSizeClass: UserInterfaceSizeClass?
-
- init(horizontalSizeClass: UserInterfaceSizeClass? = nil, verticalSizeClass: UserInterfaceSizeClass? = nil) {
- if horizontalSizeClass != nil {
- self.horizontalSizeClass = horizontalSizeClass
- } else {
- self.horizontalSizeClass = horizontalSizeClassEnvironment
- }
- if verticalSizeClass != nil {
- self.verticalSizeClass = verticalSizeClass
- } else {
- self.verticalSizeClass = verticalSizeClassEnvironment
- }
- }
-
- func makeBody(configuration: Configuration) -> some View {
- return makeBodyBase(label: configuration.label, isPressed: configuration.isPressed)
- }
- }
- struct ToolbarMenuStyle: MenuStyle, ToolbarButtonBaseStyle {
- typealias Label = Menu<Configuration.Label, Configuration.Content>
-
- @Environment(\.horizontalSizeClass) internal var horizontalSizeClass
- @Environment(\.verticalSizeClass) internal var verticalSizeClass
-
- func makeBody(configuration: Configuration) -> some View {
- return makeBodyBase(label: Menu(configuration), isPressed: false)
- }
- }
- 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)
- .foregroundStyle(.primary)
- .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
- var shakesPerUnit = 3
- var animatableData: CGFloat
-
- init(shake: Bool) {
- animatableData = shake ? 1.0 : 0.0
- }
- func effectValue(size: CGSize) -> ProjectionTransform {
- ProjectionTransform(CGAffineTransform(translationX:
- amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)),
- y: 0))
- }
- }
- extension ButtonStyle where Self == ToolbarButtonStyle {
- static var toolbar: ToolbarButtonStyle {
- ToolbarButtonStyle()
- }
-
- // this is needed to workaround a SwiftUI bug on < iOS 15
- static func toolbar(horizontalSizeClass: UserInterfaceSizeClass?, verticalSizeClass: UserInterfaceSizeClass?) -> ToolbarButtonStyle {
- ToolbarButtonStyle(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass)
- }
- }
- extension MenuStyle where Self == ToolbarMenuStyle {
- static var toolbar: ToolbarMenuStyle {
- ToolbarMenuStyle()
- }
- }
- @MainActor private class LongIdleTimeout: ObservableObject {
- private var longIdleTask: DispatchWorkItem?
-
- @Published var isUserInteracting: Bool = true
-
- private func setIsUserInteracting(_ value: Bool) {
- if !UIAccessibility.isReduceMotionEnabled {
- withAnimation {
- self.isUserInteracting = value
- }
- } else {
- self.isUserInteracting = value
- }
- }
-
- func assertUserInteraction() {
- if let task = longIdleTask {
- task.cancel()
- }
- setIsUserInteracting(true)
- longIdleTask = DispatchWorkItem {
- self.longIdleTask = nil
- self.setIsUserInteracting(false)
- }
- DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: longIdleTask!)
- }
- }
- private struct HideToolbarTipModifier: ViewModifier {
- @Binding var isCollapsed: Bool
- private let _hideToolbarTip: Any?
- @available(iOS 17, *)
- private var hideToolbarTip: UTMTipHideToolbar {
- _hideToolbarTip as! UTMTipHideToolbar
- }
- init(isCollapsed: Binding<Bool>) {
- _isCollapsed = isCollapsed
- if #available(iOS 17, *) {
- _hideToolbarTip = UTMTipHideToolbar()
- } else {
- _hideToolbarTip = nil
- }
- }
- @ViewBuilder
- func body(content: Content) -> some View {
- if #available(iOS 17, *) {
- content
- .popoverTip(hideToolbarTip, arrowEdge: .top)
- .onAppear {
- UTMTipHideToolbar.didHideToolbar = isCollapsed
- }
- } else {
- content
- }
- }
- }
|