Skip to content

Instantly share code, notes, and snippets.

@steventroughtonsmith
Last active December 23, 2023 11:05
Show Gist options
  • Save steventroughtonsmith/bbdb02d1a5c118cd66bf68a51cbb00e7 to your computer and use it in GitHub Desktop.
Save steventroughtonsmith/bbdb02d1a5c118cd66bf68a51cbb00e7 to your computer and use it in GitHub Desktop.
WIP tooltips for Mac Catalyst
//
// UIView+Tooltips.h
// Crossword
//
// Created by Steven Troughton-Smith on 13/09/2019.
// Copyright © 2019 Steven Troughton-Smith. All rights reserved.
//
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface UITooltipView : UIView
@property BOOL visible;
+ (instancetype)sharedInstance;
-(void)showFromRect:(CGRect)windowRect;
-(void)hide;
@property UILabel *tooltipLabel;
/* Private */
-(void)_sizeToFit;
@end
@interface UIView (Tooltips)
@property NSString *toolTip;
/* Private */
@property BOOL _tooltipMouseOver;
@property UIHoverGestureRecognizer *_tooltipRecognizer;
@end
NS_ASSUME_NONNULL_END
//
// UIView+Tooltips.m
// Crossword
//
// Created by Steven Troughton-Smith on 13/09/2019.
// Copyright © 2019 Steven Troughton-Smith. All rights reserved.
//
#import "UIView+Tooltips.h"
#import <objc/runtime.h>
@implementation UITooltipView
+ (instancetype)sharedInstance
{
static UITooltipView *sharedInstance = nil;
static dispatch_once_t onceToken; // onceToken = 0
dispatch_once(&onceToken, ^{
sharedInstance = [[UITooltipView alloc] init];
});
return sharedInstance;
}
- (instancetype)init
{
self = [super init];
if (self) {
UIBlurEffect *blurEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleSystemMaterial];
UIVisualEffectView *blurView = [[UIVisualEffectView alloc] initWithEffect:blurEffect];
blurView.bounds = self.bounds;
blurView.autoresizingMask = UIViewAutoresizingFlexibleWidth|UIViewAutoresizingFlexibleHeight;
[self addSubview:blurView];
self.tooltipLabel = [[UILabel alloc] init];
self.tooltipLabel.text = @"";
self.tooltipLabel.font = [UIFont systemFontOfSize:14];
self.tooltipLabel.autoresizingMask = UIViewAutoresizingFlexibleWidth|UIViewAutoresizingFlexibleHeight;
self.tooltipLabel.lineBreakMode = NSLineBreakByClipping;
[[blurView contentView] addSubview:self.tooltipLabel];
self.layer.borderWidth = 1.0/[UIScreen mainScreen].scale;
self.layer.borderColor = [UIColor secondarySystemFillColor].CGColor;
self.layer.shadowColor = [UIColor blackColor].CGColor;
self.layer.shadowRadius = 6;
self.layer.shadowOffset = CGSizeMake(0, 0);
self.layer.shadowOpacity = 0.3;
}
return self;
}
-(void)_sizeToFit
{
[self.tooltipLabel sizeToFit];
self.frame = CGRectInset(self.tooltipLabel.bounds, -5, -3);
self.tooltipLabel.frame = CGRectInset(self.bounds, 5, 3);
}
-(void)showFromRect:(CGRect)convertedRect
{
[CATransaction setDisableActions:YES];
[self _sizeToFit];
CGRect labelRect = self.bounds;
self.alpha = 0;
self.frame = CGRectMake(convertedRect.origin.x+convertedRect.size.width/2-labelRect.size.width/2, convertedRect.origin.y+convertedRect.size.height, self.frame.size.width, self.frame.size.height);
[CATransaction setDisableActions:NO];
self.visible = YES;
[UIView animateWithDuration:0.1 animations:^{
self.alpha = 1;
} completion:nil];
}
-(void)hide
{
[UIView animateWithDuration:0.1 animations:^{
self.alpha = 0;
} completion:^(BOOL finished) {
self.visible = NO;
}];
}
@end
@implementation UIView (Tooltips)
#pragma mark - Properties
-(void)set_tooltipMouseOver:(BOOL)tooltipMouseOver
{
objc_setAssociatedObject(self, @selector(_tooltipMouseOver), @(tooltipMouseOver), OBJC_ASSOCIATION_ASSIGN);
}
- (BOOL)_tooltipMouseOver
{
return [objc_getAssociatedObject(self, @selector(_tooltipMouseOver)) boolValue];
}
-(void)set_tooltipRecognizer:(UIHoverGestureRecognizer *)obj
{
objc_setAssociatedObject(self, @selector(_tooltipRecognizer), obj, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (UIHoverGestureRecognizer *)_tooltipRecognizer
{
return objc_getAssociatedObject(self, @selector(_tooltipRecognizer));
}
-(void)setToolTip:(NSString *)obj
{
objc_setAssociatedObject(self, @selector(toolTip), obj, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
if (!self._tooltipRecognizer && obj && [obj length])
[self _installTooltipRecognizer];
}
- (NSString *)toolTip
{
return objc_getAssociatedObject(self, @selector(toolTip));
}
#pragma mark - Recognizer
-(void)_installTooltipRecognizer
{
self._tooltipRecognizer = [[UIHoverGestureRecognizer alloc] initWithTarget:self action:@selector(tooltipHover:)];
[self addGestureRecognizer:self._tooltipRecognizer];
}
-(void)_beginTooltipTimer
{
CGFloat time = 2.0;
if ([UITooltipView sharedInstance].visible)
time = 0.0;
[NSTimer scheduledTimerWithTimeInterval:time repeats:NO block:^(NSTimer * _Nonnull timer) {
if (self._tooltipMouseOver)
{
CGRect convertedRect = [self convertRect:self.bounds toView:self.window];
[UITooltipView sharedInstance].tooltipLabel.text = self.toolTip;
[self.window addSubview:[UITooltipView sharedInstance]];
[[UITooltipView sharedInstance] showFromRect:convertedRect];
}
}];
}
-(void)tooltipHover:(UIHoverGestureRecognizer *)recognizer
{
switch (recognizer.state)
{
case UIGestureRecognizerStateBegan:
{
self._tooltipMouseOver = YES;
[self _beginTooltipTimer];
break;
}
case UIGestureRecognizerStateChanged:
{
break;
}
case UIGestureRecognizerStateEnded:
case UIGestureRecognizerStateCancelled:
case UIGestureRecognizerStateFailed:
{
self._tooltipMouseOver = NO;
[[UITooltipView sharedInstance] hide];
break;
}
default:
break;
}
}
@end
@pmacro
Copy link

pmacro commented Nov 14, 2020

Thanks for sharing this, @steventroughtonsmith!

Here's a rough Swift conversion, for anyone who might find it useful.

//
//  TooltipView.swift
//  Draft
//
//  Created by Paul MacRory on 13/11/2020.
//  Copyright © 2020 pmacro. All rights reserved.
//
// Based on https://gist.github.com/steventroughtonsmith/bbdb02d1a5c118cd66bf68a51cbb00e7 by Steven Troughton-Smith

import Foundation
import UIKit

private final class TooltipView: UIView {
  
  fileprivate var isPresented = false
  
  static let shared: TooltipView = {
    TooltipView()
  }()
  
  let tooltipLabel = UILabel()
  
  init() {
    super.init(frame: .zero)
    
    let blurEffect = UIBlurEffect(style: .systemMaterial)
    let blurView = UIVisualEffectView(effect: blurEffect)
    blurView.bounds = self.bounds
    blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        
    addSubview(blurView)
        
    tooltipLabel.font = .preferredFont(forTextStyle: .caption1)
    tooltipLabel.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    tooltipLabel.lineBreakMode = .byClipping
        
    blurView.contentView.addSubview(self.tooltipLabel)
        
    layer.borderWidth = 1.0 / UIScreen.main.scale
    layer.borderColor = UIColor.secondarySystemFill.cgColor
    layer.shadowColor = UIColor.black.cgColor
    layer.shadowRadius = 6
    layer.shadowOffset = .zero
    layer.shadowOpacity = 0.3
  }
  
  @available(*, unavailable)
  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  override func sizeToFit() {
    tooltipLabel.sizeToFit()
    
    frame = tooltipLabel.bounds.insetBy(dx: -5, dy: -3)
    tooltipLabel.frame = bounds.insetBy(dx: 5, dy: 3)
  }
  
  func showFromRect(_ convertedRect: CGRect) {
    CATransaction.setDisableActions(true)
    sizeToFit()
    
    let labelRect = bounds
    alpha = 0
    frame = CGRect(
      x: convertedRect.origin.x + convertedRect.size.width / 2 - labelRect.size.width / 2,
      y: convertedRect.origin.y + convertedRect.size.height,
      width: frame.size.width,
      height: frame.size.height
    )
    
    CATransaction.setDisableActions(false)

    isPresented = true
    
    UIView.animate(withDuration: 0.1) {
      self.alpha = 1
    }
  }

  func hide() {
    UIView.animate(withDuration: 0.1, animations: {
      self.alpha = 0
    }, completion: { _ in
      self.isPresented = false
    })
  }
}

extension UIView {
  
  private struct AssociatedKeys {
    static var tooltipMouseOver: UInt8 = 0
    static var tooltipRecognizer: UInt8 = 1
    static var tooltip: UInt8 = 2
  }
 
  private var tooltipMouseOver: Bool {
    set {
      objc_setAssociatedObject(
        self,
        &AssociatedKeys.tooltipMouseOver,
        newValue,
        .OBJC_ASSOCIATION_ASSIGN
      )
    }

    get {
      objc_getAssociatedObject(self, &AssociatedKeys.tooltipMouseOver) != nil
    }
  }
  
  private var tooltipRecognizer: UIHoverGestureRecognizer? {
    set {
      objc_setAssociatedObject(self, &AssociatedKeys.tooltipRecognizer, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    }

    get {
      objc_getAssociatedObject(self, &AssociatedKeys.tooltipRecognizer) as? UIHoverGestureRecognizer
    }
  }
  
  public var tooltip: String? {
    set {
      objc_setAssociatedObject(self, &AssociatedKeys.tooltip, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
      
      if tooltipRecognizer == nil, let string = newValue, !string.isEmpty {
        installTooltipRecognizer()
      }
    }

    get {
      objc_getAssociatedObject(self, &AssociatedKeys.tooltip) as? String
    }
  }

  private func installTooltipRecognizer() {
    let recognizer = UIHoverGestureRecognizer(target: self, action: #selector(tooltipHover))
    tooltipRecognizer = recognizer
    addGestureRecognizer(recognizer)
  }

  private func beginTooltipTimer() {
    var time = 2.0
    
    if TooltipView.shared.isPresented {
      time = 0.0
    }
    
    Timer.scheduledTimer(withTimeInterval: time, repeats: false) { timer in
      if self.tooltipMouseOver {
        let convertedRect = self.convert(self.bounds, to: self.window)
        
        TooltipView.shared.tooltipLabel.text = self.tooltip
        self.window?.addSubview(TooltipView.shared)
        TooltipView.shared.showFromRect(convertedRect)
      }
    }
  }

  @objc
  private func tooltipHover(recognizer: UIHoverGestureRecognizer) {
    switch (recognizer.state)
    {
    case .began:
      tooltipMouseOver = true
      beginTooltipTimer()
    case .changed:
      break
    case .ended, .cancelled, .failed:
      tooltipMouseOver = false
      TooltipView.shared.hide()
      default:
        break;
    }
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment