在 iOS 中,如何创建一个始终位于所有其他视图控制器之上的按钮?

Posted

技术标签:

【中文标题】在 iOS 中,如何创建一个始终位于所有其他视图控制器之上的按钮?【英文标题】:In iOS, how do I create a button that is always on top of all other view controllers? 【发布时间】:2016-01-13 21:39:35 【问题描述】:

无论是显示模态还是用户执行任何类型的转场。

有没有办法让按钮在整个应用程序中“始终位于顶部”(而不是屏幕顶部)?

有什么方法可以让这个按钮可以拖拽到屏幕上?

我将 Apple 自己的 Assistive Touch 视为此类按钮的示例。

【问题讨论】:

您是否要求将按钮限制在顶部? 谁的模态?这些模态来自哪里? 模态框可能来自任何调用它们的 ViewController。 像UIAlertView这样的模态,像位置访问权限请求这样的模态,像-[UIViewController presentModalViewController:]这样的模态...? 【参考方案1】:

您可以通过创建自己的UIWindow 子类,然后创建该子类的实例来做到这一点。你需要对窗口做三件事:

    将其windowLevel 设置为一个非常高的数字,例如CGFloat.max。它被限制在(从 ios 9.2 开始)为 10000000,但您最好将其设置为可能的最高值。

    将窗口的背景颜色设置为 nil 以使其透明。

    覆盖窗口的pointInside(_:withEvent:) 方法以仅对按钮中的点返回true。这将使窗口只接受点击按钮的触摸,所有其他触摸都将传递给其他窗口。

然后为窗口创建一个根视图控制器。创建按钮并将其添加到视图层次结构中,并将其告知窗口,以便窗口可以在pointInside(_:withEvent:) 中使用它。

还有最后一件事要做。事实证明,屏幕键盘也使用最高的窗口级别,并且由于它可能在您的窗口之后出现在屏幕上,因此它将位于您的窗口顶部。您可以通过观察 UIKeyboardDidShowNotification 并在发生这种情况时重置窗口的 windowLevel 来解决此问题(因为这样做是为了将您的窗口置于同一级别的所有窗口之上)。

这是一个演示。我将从将在窗口中使用的视图控制器开始。

import UIKit

class FloatingButtonController: UIViewController 

这是按钮实例变量。创建FloatingButtonController 的任何人都可以访问该按钮以向其添加目标/操作。我稍后会证明这一点。

    private(set) var button: UIButton!

你必须“实现”这个初始化器,但我不会在故事板中使用这个类。

    required init?(coder aDecoder: NSCoder) 
        fatalError()
    

这是真正的初始化程序。

    init() 
        super.init(nibName: nil, bundle: nil)
        window.windowLevel = CGFloat.max
        window.hidden = false
        window.rootViewController = self

设置window.hidden = false 将其显示在屏幕上(当当前CATransaction 提交时)。我还需要注意键盘是否出现:

        NSNotificationCenter.defaultCenter().addObserver(self, selector: "keyboardDidShow:", name: UIKeyboardDidShowNotification, object: nil)
    

我需要一个对我的窗口的引用,这将是一个自定义类的实例:

    private let window = FloatingButtonWindow()

我将在代码中创建我的视图层次结构以保持这个答案自包含:

    override func loadView() 
        let view = UIView()
        let button = UIButton(type: .Custom)
        button.setTitle("Floating", forState: .Normal)
        button.setTitleColor(UIColor.greenColor(), forState: .Normal)
        button.backgroundColor = UIColor.whiteColor()
        button.layer.shadowColor = UIColor.blackColor().CGColor
        button.layer.shadowRadius = 3
        button.layer.shadowOpacity = 0.8
        button.layer.shadowOffset = CGSize.zero
        button.sizeToFit()
        button.frame = CGRect(origin: CGPointMake(10, 10), size: button.bounds.size)
        button.autoresizingMask = []
        view.addSubview(button)
        self.view = view
        self.button = button
        window.button = button

那里没有什么特别的。我只是在创建我的根视图并在其中放置一个按钮。

为了允许用户拖动按钮,我将在按钮上添加一个平移手势识别器:

        let panner = UIPanGestureRecognizer(target: self, action: "panDidFire:")
        button.addGestureRecognizer(panner)
    

窗口将在第一次出现时布置其子视图,并在调整大小时(特别是因为界面旋转),所以我想在这些时候重新定位按钮:

    override func viewDidLayoutSubviews() 
        super.viewDidLayoutSubviews()
        snapButtonToSocket()
    

(稍后将详细了解snapButtonToSocket。)

为了处理按钮的拖动,我以标准方式使用平移手势识别器:

    func panDidFire(panner: UIPanGestureRecognizer) 
        let offset = panner.translationInView(view)
        panner.setTranslation(CGPoint.zero, inView: view)
        var center = button.center
        center.x += offset.x
        center.y += offset.y
        button.center = center

您要求“捕捉”,所以如果平移结束或取消,我会将按钮捕捉到我称之为“套接字”的固定数量的位置之一:

        if panner.state == .Ended || panner.state == .Cancelled 
            UIView.animateWithDuration(0.3) 
                self.snapButtonToSocket()
            
        
    

我通过重置window.windowLevel来处理键盘通知:

    func keyboardDidShow(note: NSNotification) 
        window.windowLevel = 0
        window.windowLevel = CGFloat.max
    

要将按钮捕捉到插槽,我会找到离按钮位置最近的插槽并将按钮移动到那里。请注意,这不一定是您想要的界面旋转,但我会为读者留下一个更完美的解决方案作为练习。无论如何,它会在旋转后将按钮保留在屏幕上。

    private func snapButtonToSocket() 
        var bestSocket = CGPoint.zero
        var distanceToBestSocket = CGFloat.infinity
        let center = button.center
        for socket in sockets 
            let distance = hypot(center.x - socket.x, center.y - socket.y)
            if distance < distanceToBestSocket 
                distanceToBestSocket = distance
                bestSocket = socket
            
        
        button.center = bestSocket
    

我在屏幕的每个角落都放了一个插座,中间放了一个用于演示目的:

    private var sockets: [CGPoint] 
        let buttonSize = button.bounds.size
        let rect = view.bounds.insetBy(dx: 4 + buttonSize.width / 2, dy: 4 + buttonSize.height / 2)
        let sockets: [CGPoint] = [
            CGPointMake(rect.minX, rect.minY),
            CGPointMake(rect.minX, rect.maxY),
            CGPointMake(rect.maxX, rect.minY),
            CGPointMake(rect.maxX, rect.maxY),
            CGPointMake(rect.midX, rect.midY)
        ]
        return sockets
    


最后,自定义UIWindow 子类:

private class FloatingButtonWindow: UIWindow 

    var button: UIButton?

    init() 
        super.init(frame: UIScreen.mainScreen().bounds)
        backgroundColor = nil
    

    required init?(coder aDecoder: NSCoder) 
        fatalError("init(coder:) has not been implemented")
    

正如我所提到的,我需要覆盖 pointInside(_:withEvent:) 以便窗口忽略按钮外部的触摸:

    private override func pointInside(point: CGPoint, withEvent event: UIEvent?) -> Bool 
        guard let button = button else  return false 
        let buttonPoint = convertPoint(point, toView: button)
        return button.pointInside(buttonPoint, withEvent: event)
    


现在你如何使用这个东西?我下载了Apple's AdaptivePhotos sample project 并将我的FloatingButtonController.swift 文件添加到AdaptiveCode 目标。我给AppDelegate添加了一个属性:

var floatingButtonController: FloatingButtonController?

然后我在application(_:didFinishLaunchingWithOptions:)的末尾添加代码来创建FloatingButtonController

    floatingButtonController = FloatingButtonController()
    floatingButtonController?.button.addTarget(self, action: "floatingButtonWasTapped", forControlEvents: .TouchUpInside)

这些行就在函数末尾的return true 之前。我还需要写按钮的动作方法:

func floatingButtonWasTapped() 
    let alert = UIAlertController(title: "Warning", message: "Don't do that!", preferredStyle: .Alert)
    let action = UIAlertAction(title: "Sorry…", style: .Default, handler: nil)
    alert.addAction(action)
    window?.rootViewController?.presentViewController(alert, animated: true, completion: nil)

这就是我所要做的。但为了演示目的,我还做了一件事:在 AboutViewController 中,我将 label 更改为 UITextView,这样我就有办法调出键盘了。

按钮如下所示:

这是点击按钮的效果。请注意,按钮浮动在警报上方:

当我调出键盘时会发生以下情况:

它是否处理旋转?你打赌:

好吧,旋转处理并不完美,因为旋转后哪个套接字最接近按钮可能与旋转前的“逻辑”套接字不同。您可以通过跟踪按钮上次捕捉到哪个插槽并专门处理旋转(通过检测大小变化)来解决此问题。

为了您的方便,我将整个FloatingViewController.swift 放在this gist 中。

【讨论】:

其实,看View Programming Guide for iOS 让我觉得你可能也想观察UIWindowDidBecomeVisibleNotification 并在收到时重置window.windowLevel 很好的解释。你也可以提供 Objective-C 版本吗@rob? 直接引用苹果的话:“要改变你的应用显示的内容,你可以改变窗口的根视图;你不创建一个新窗口。”; “大多数 iOS 应用程序在其生命周期内只创建和使用一个窗口。......但是,如果应用程序支持使用外部显示器进行视频输出,它可以创建一个额外的窗口......所有其他窗口通常由系统”;等等,等等。就像每一个第三方在 UIAlertView 上的尝试一样(在 Apple 认为窗口级模式是一种破坏模式之前),预计这种方法会经常破坏。 UIWindow class reference 已更新,以阐明是否可以接受创建多个窗口。它现在显示“创建附加窗口(根据需要)以显示附加内容”、“您可以创建附加窗口并将它们显示在设备的主屏幕上”和“您还可以在同一屏幕上显示多个窗口”。另请参阅 WWDC 2016 Session 601(“Go Live with ReplayKit”),其中 Apple 工程师逐步创建了第二个窗口。 我不想花时间在这上面。如果您熟悉 UIKit,即使您不了解 Swift,将代码转换为 Objective-C 也应该很容易。【参考方案2】:

在 Swift 3 中:

import UIKit

private class FloatingButtonWindow: UIWindow 
    var button: UIButton?

    var floatingButtonController: FloatingButtonController?

    init() 
        super.init(frame: UIScreen.main.bounds)
        backgroundColor = nil
    

    required init?(coder aDecoder: NSCoder) 
        fatalError("init(coder:) has not been implemented")
    

    fileprivate override func point(inside point: CGPoint, with event: UIEvent?) -> Bool 
        guard let button = button else  return false 
        let buttonPoint = convert(point, to: button)
        return button.point(inside: buttonPoint, with: event)
    


class FloatingButtonController: UIViewController 
    private(set) var button: UIButton!

    private let window = FloatingButtonWindow()

    required init?(coder aDecoder: NSCoder) 
        fatalError()
    

    init() 
        super.init(nibName: nil, bundle: nil)
        window.windowLevel = CGFloat.greatestFiniteMagnitude
        window.isHidden = false
        window.rootViewController = self
        NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: NSNotification.Name.UIKeyboardDidShow, object: nil)
    

    func keyboardDidShow(note: NSNotification) 
        window.windowLevel = 0
        window.windowLevel = CGFloat.greatestFiniteMagnitude
    

    override func loadView() 
        let view = UIView()
        let button = UIButton(type: .custom)
        button.setTitle("Floating", for: .normal)
        button.setTitleColor(UIColor.green, for: .normal)
        button.backgroundColor = UIColor.white
        button.layer.shadowColor = UIColor.black.cgColor
        button.layer.shadowRadius = 3
        button.layer.shadowOpacity = 0.8
        button.layer.shadowOffset = CGSize.zero
        button.sizeToFit()
        button.frame = CGRect(origin: CGPoint(x: 10, y: 10), size: button.bounds.size)
        button.autoresizingMask = []
        view.addSubview(button)
        self.view = view
        self.button = button
        window.button = button


        let panner = UIPanGestureRecognizer(target: self, action: #selector(panDidFire))
        button.addGestureRecognizer(panner)
    

    func panDidFire(panner: UIPanGestureRecognizer) 
        let offset = panner.translation(in: view)
        panner.setTranslation(CGPoint.zero, in: view)
        var center = button.center
        center.x += offset.x
        center.y += offset.y
        button.center = center

        if panner.state == .ended || panner.state == .cancelled 
            UIView.animate(withDuration: 0.3) 
                self.snapButtonToSocket()
            
        
    

    override func viewDidLayoutSubviews() 
        super.viewDidLayoutSubviews()
        snapButtonToSocket()
    

    private var sockets: [CGPoint] 
        let buttonSize = button.bounds.size
        let rect = view.bounds.insetBy(dx: 4 + buttonSize.width / 2, dy: 4 + buttonSize.height / 2)
        let sockets: [CGPoint] = [
            CGPoint(x: rect.minX, y: rect.minY),
            CGPoint(x: rect.minX, y: rect.maxY),
            CGPoint(x: rect.maxX, y: rect.minY),
            CGPoint(x: rect.maxX, y: rect.maxY),
            CGPoint(x: rect.midX, y: rect.midY)
        ]
        return sockets
    

    private func snapButtonToSocket() 
        var bestSocket = CGPoint.zero
        var distanceToBestSocket = CGFloat.infinity
        let center = button.center
        for socket in sockets 
            let distance = hypot(center.x - socket.x, center.y - socket.y)
            if distance < distanceToBestSocket 
                distanceToBestSocket = distance
                bestSocket = socket
            
        
        button.center = bestSocket
    

在 AppDelegate 中:

    var floatingButtonController: FloatingButtonController?

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool 
    // Override point for customization after application launch.

     floatingButtonController = FloatingButtonController()
     floatingButtonController?.button.addTarget(self, action: #selector(AppDelegate.floatingButtonWasTapped), for: .touchUpInside)


    return true


func floatingButtonWasTapped() 
    let alert = UIAlertController(title: "Warning", message: "Don't do that!", preferredStyle: .alert)
    let action = UIAlertAction(title: "Sorry…", style: .default, handler: nil)
    alert.addAction(action)
    window?.rootViewController?.present(alert, animated: true, completion: nil)

【讨论】:

如何关闭这个窗口?【参考方案3】:

Swift 4.2 - UIViewController 扩展

Rob Mayoff 对本地浮动按钮的回答的修改版本。

UIViewController 扩展可以让您更好地控制浮动按钮。但是,它将被添加到您称为 addFloatingButton 的单个视图控制器上,而不是所有视图控制器上。

在特定视图控制器中添加浮动按钮可以将视图控制器特定操作添加到浮动按钮。 经常使用topViewController功能不会阻塞。 (如下提供) 如果您使用浮动按钮来切换应用程序的聊天支持(例如 Zendesk Chat API),UIViewController 扩展方法可以让您更好地控制视图控制器之间的导航。

对于这个问题,这可能不是最佳做法,但是 这种对浮动按钮的控制应该启用不同的 用于不同目的的实践。

UIViewController 扩展

import UIKit

extension UIViewController 
    private struct AssociatedKeys 
        static var floatingButton: UIButton?
    

    var floatingButton: UIButton? 
        get 
            guard let value = objc_getAssociatedObject(self, &AssociatedKeys.floatingButton) as? UIButton else return nil
            return value
        
        set(newValue) 
            objc_setAssociatedObject(self, &AssociatedKeys.floatingButton, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        
    

    func addFloatingButton() 
        // Customize your own floating button UI
        let button = UIButton(type: .custom)
        let image = UIImage(named: "tab_livesupport_unselected")?.withRenderingMode(.alwaysTemplate)
        button.tintColor = .white
        button.setImage(image, for: .normal)
        button.backgroundColor = UIColor.obiletGreen
        button.layer.shadowColor = UIColor.black.cgColor
        button.layer.shadowRadius = 3
        button.layer.shadowOpacity = 0.12
        button.layer.shadowOffset = CGSize(width: 0, height: 1)
        button.sizeToFit()
        let buttonSize = CGSize(width: 60, height: 60)
        let rect = UIScreen.main.bounds.insetBy(dx: 4 + buttonSize.width / 2, dy: 4 + buttonSize.height / 2)
        button.frame = CGRect(origin: CGPoint(x: rect.maxX - 15, y: rect.maxY - 50), size: CGSize(width: 60, height: 60))
        // button.cornerRadius = 30 -> Will destroy your shadows, however you can still find workarounds for rounded shadow.
        button.autoresizingMask = []
        view.addSubview(button)
        floatingButton = button
        let panner = UIPanGestureRecognizer(target: self, action: #selector(panDidFire(panner:)))
        floatingButton?.addGestureRecognizer(panner)
        snapButtonToSocket()
    

    @objc fileprivate func panDidFire(panner: UIPanGestureRecognizer) 
        guard let floatingButton = floatingButton else return
        let offset = panner.translation(in: view)
        panner.setTranslation(CGPoint.zero, in: view)
        var center = floatingButton.center
        center.x += offset.x
        center.y += offset.y
        floatingButton.center = center

        if panner.state == .ended || panner.state == .cancelled 
            UIView.animate(withDuration: 0.3) 
                self.snapButtonToSocket()
            
        
    

    fileprivate func snapButtonToSocket() 
        guard let floatingButton = floatingButton else return
        var bestSocket = CGPoint.zero
        var distanceToBestSocket = CGFloat.infinity
        let center = floatingButton.center
        for socket in sockets 
            let distance = hypot(center.x - socket.x, center.y - socket.y)
            if distance < distanceToBestSocket 
                distanceToBestSocket = distance
                bestSocket = socket
            
        
        floatingButton.center = bestSocket
    

    fileprivate var sockets: [CGPoint] 
        let buttonSize = floatingButton?.bounds.size ?? CGSize(width: 0, height: 0)
        let rect = view.bounds.insetBy(dx: 4 + buttonSize.width / 2, dy: 4 + buttonSize.height / 2)
        let sockets: [CGPoint] = [
            CGPoint(x: rect.minX + 15, y: rect.minY + 30),
            CGPoint(x: rect.minX + 15, y: rect.maxY - 50),
            CGPoint(x: rect.maxX - 15, y: rect.minY + 30),
            CGPoint(x: rect.maxX - 15, y: rect.maxY - 50)
        ]
        return sockets
    
    // Custom socket position to hold Y position and snap to horizontal edges.
    // You can snap to any coordinate on screen by setting custom socket positions.
    fileprivate var horizontalSockets: [CGPoint] 
        guard let floatingButton = floatingButton else return []
        let buttonSize = floatingButton.bounds.size
        let rect = view.bounds.insetBy(dx: 4 + buttonSize.width / 2, dy: 4 + buttonSize.height / 2)
        let y = min(rect.maxY - 50, max(rect.minY + 30, floatingButton.frame.minY + buttonSize.height / 2))
        let sockets: [CGPoint] = [
            CGPoint(x: rect.minX + 15, y: y),
            CGPoint(x: rect.maxX - 15, y: y)
        ]
        return sockets
    



UIViewController 用法

我更喜欢在 viewDidLoad(_ animated:) 之后添加浮动按钮。 如果之后另一个子视图阻止了浮动按钮,它可能需要调用 bringSubviewToFront()

override func viewDidAppear(_ animated: Bool) 
        super.viewDidAppear(animated)
        addFloatingButton()
        floatingButton?.addTarget(self, action: #selector(floatingButtonPressed), for: .touchUpInside)
    

    @objc func floatingButtonPressed()
        print("Floating button tapped")
    

UIApplication - 顶视图控制器

extension UIApplication

    class func topViewController(controller: UIViewController? = UIApplication.shared.keyWindow?.rootViewController) -> UIViewController? 
        if let navigationController = controller as? UINavigationController 
            return topViewController(controller: navigationController.visibleViewController)
        
        if let tabController = controller as? UITabBarController 
            if let selected = tabController.selectedViewController 
                return topViewController(controller: selected)
            
        
        if let presented = controller?.presentedViewController 
            return topViewController(controller: presented)
        
        return controller
    

【讨论】:

做得很好,但我希望按钮停止在 Y 轴的中间。现在它只是粘在屏幕的底部。我怎样才能实现它? 谢谢,您可以更改套接字位置以将按钮停止在 Y 轴的中间。我将添加一个自定义套接字位置作为指南 -> 命名为 horizo​​ntalSockets 哇,非常感谢。 floatingButton?.setTitle("Title", for: .normal) 不起作用。请编辑您的答案。【参考方案4】:

Apple 在这种情况下的意图是让您的根视图控制器包含按钮和您想要的任何触摸逻辑,并且它还将作为子视图控制器嵌入运行应用程序的其余部分。您不打算直接与 UIWindow 交谈,如果您这样做,可能会遇到一大堆问题,例如跨 iOS 版本的自动旋转。

旧代码仍然可以使用 UIAlertView 等已弃用的类在顶部显示模式。 Apple 已明确切换到 UIAlertController 之类的方法,其中被覆盖的东西明确拥有覆盖它的东西,以便允许正确的视图控制器包含。您可能应该做出同样的假设,并拒绝使用位于已弃用代码上的组件。

【讨论】:

我有一个blankViewController,它的容器视图填满了整个屏幕。容器视图嵌入了一个 tabBarViewController(这是我的主应用程序)。我在blankViewController 中创建了一个视图,它仍然存在。但是,只要有模式,它就会覆盖视图。【参考方案5】:

您可以使用Paramount,它基本上使用另一个UIWindow 来显示您的按钮

Manager.action = 
  print("action touched")


Manager.show()

【讨论】:

【参考方案6】:

您可以使用视图层的zPosition 属性(它是一个CALayer 对象)来更改视图的z-index,您还需要将此按钮或您的视图添加为您的子视图窗口不是您的任何其他视图控制器的子视图,其默认值为 0。但您可以像这样更改它:

yourButton.layer.zPosition = 1;

您需要导入 QuartzCore 框架才能访问该层。只需在实现文件的顶部添加这行代码即可。

#import "QuartzCore/QuartzCore.h"

希望这会有所帮助。

【讨论】:

这使得按钮一直出现,但在某些情况下变得没有响应。【参考方案7】:

在 UIWindow 上添加按钮,它将浮动在应用程序中的所有视图上

【讨论】:

【参考方案8】:

在此展示它:

public static var topMostVC: UIViewController? 
    var presentedVC = UIApplication.sharedApplication().keyWindow?.rootViewController
    while let pVC = presentedVC?.presentedViewController 
        presentedVC = pVC
    

    if presentedVC == nil 
        print("EZSwiftExtensions Error: You don't have any views set. You may be calling them in viewDidLoad. Try viewDidAppear instead.")
    
    return presentedVC


topMostVC?.presentViewController(myAlertController, animated: true, completion: nil)

//or

let myView = UIView()
topMostVC?.view?.addSubview(myView)

这些作为标准功能包含在我的项目中:https://github.com/goktugyil/EZSwiftExtensions

【讨论】:

如果你要发布指向你自己的 GitHub 项目的链接,你应该让人们知道它是你的。否则,您的帖子可能会被标记为垃圾邮件。 我忘记了这个,如果你看到这样的帖子,请随时编辑。【参考方案9】:

对于 Objective-c

要在 objetive-c 的 AppDelegate 中使用 FloatingButtonController 类,只需执行以下操作

声明了一个 FloatingButtonController 类型的变量

FloatingButtonController *flotatingButton;

然后在下面添加方法

   - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions 
        flotatingButton = [[FloatingButtonController alloc] init];
        [flotatingButton addTargetWithTarget:self selector:@selector(floatingButtonDidReceiveTouchUpInside)];

然后创建添加到按钮操作的方法

- (void)floatingButtonDidReceiveTouchUpInside 
    logsView = [ModulosApp supportLogWithDate:[NSDate date]];
    [self.window.rootViewController presentViewController:YOUR_VIEWCONTROLLER animated:YES completion:nil];
    
    

这样我们可以在objective-c的AppDelegate中快速调用该类

【讨论】:

以上是关于在 iOS 中,如何创建一个始终位于所有其他视图控制器之上的按钮?的主要内容,如果未能解决你的问题,请参考以下文章

如何在 Codeigniter 中使视图“可移植”?

如何使页脚视图始终位于 UITableViewController 的底部?

如何使表单始终位于任务栏顶部

在 iOS 的 UICollectionview 底部添加按钮

如何在屏幕底部对齐视图,但也在相对布局中的其他视图下方对齐

Android - 自定义列表视图项始终位于左侧