Przeglądaj źródła

Add animated circular hover ring on signed-in Google profile

Wrap the auth button in GoogleProfileAuthHostView, expand layout inset when
showing the avatar, and pulse the accent ring line width on hover. Clear the
pill border/background in avatar mode so the ring reads as the hover affordance.

Made-with: Cursor
huzaifahayat12 1 tydzień temu
rodzic
commit
b3bf889096
1 zmienionych plików z 134 dodań i 1 usunięć
  1. 134 1
      meetings_app/ViewController.swift

+ 134 - 1
meetings_app/ViewController.swift

@@ -6,6 +6,7 @@
6 6
 //
7 7
 
8 8
 import Cocoa
9
+import QuartzCore
9 10
 import WebKit
10 11
 import AuthenticationServices
11 12
 
@@ -85,6 +86,9 @@ final class ViewController: NSViewController {
85 86
     private weak var scheduleScrollRightButton: NSView?
86 87
     private weak var scheduleFilterDropdown: NSPopUpButton?
87 88
     private weak var scheduleGoogleAuthButton: NSButton?
89
+    private weak var scheduleGoogleAuthHostView: GoogleProfileAuthHostView?
90
+    private var scheduleGoogleAuthHostPadWidthConstraint: NSLayoutConstraint?
91
+    private var scheduleGoogleAuthHostPadHeightConstraint: NSLayoutConstraint?
88 92
     private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
89 93
     private var scheduleGoogleAuthButtonHeightConstraint: NSLayoutConstraint?
90 94
     /// Circular avatar size when signed in (top-right, Google-style).
@@ -1847,10 +1851,25 @@ private extension ViewController {
1847 1851
         row.addArrangedSubview(spacer)
1848 1852
         spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
1849 1853
 
1854
+        let host = GoogleProfileAuthHostView()
1855
+        host.translatesAutoresizingMaskIntoConstraints = false
1850 1856
         let authButton = makeGoogleAuthButton()
1857
+        host.authButton = authButton
1858
+        scheduleGoogleAuthHostView = host
1851 1859
         scheduleGoogleAuthButton = authButton
1860
+        host.addSubview(authButton)
1861
+        NSLayoutConstraint.activate([
1862
+            authButton.centerXAnchor.constraint(equalTo: host.centerXAnchor),
1863
+            authButton.centerYAnchor.constraint(equalTo: host.centerYAnchor)
1864
+        ])
1865
+        let hostPadW = host.widthAnchor.constraint(equalTo: authButton.widthAnchor, constant: 0)
1866
+        let hostPadH = host.heightAnchor.constraint(equalTo: authButton.heightAnchor, constant: 0)
1867
+        hostPadW.isActive = true
1868
+        hostPadH.isActive = true
1869
+        scheduleGoogleAuthHostPadWidthConstraint = hostPadW
1870
+        scheduleGoogleAuthHostPadHeightConstraint = hostPadH
1852 1871
         updateGoogleAuthButtonTitle()
1853
-        row.addArrangedSubview(authButton)
1872
+        row.addArrangedSubview(host)
1854 1873
 
1855 1874
         row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
1856 1875
         return row
@@ -1928,6 +1947,7 @@ private extension ViewController {
1928 1947
         scheduleGoogleAuthButtonWidthConstraint = widthConstraint
1929 1948
         button.onHoverChanged = { [weak self] hovering in
1930 1949
             self?.scheduleGoogleAuthHovering = hovering
1950
+            self?.scheduleGoogleAuthHostView?.setProfileHoverActive(hovering)
1931 1951
             self?.applyGoogleAuthButtonSurface()
1932 1952
         }
1933 1953
         button.onHoverChanged?(false)
@@ -2164,6 +2184,103 @@ extension ViewController: NSWindowDelegate {
2164 2184
     }
2165 2185
 }
2166 2186
 
2187
+/// Wraps the Google auth control and draws a circular accent ring with a light pulse while the signed-in avatar is hovered.
2188
+private final class GoogleProfileAuthHostView: NSView {
2189
+    weak var authButton: NSButton? {
2190
+        didSet { needsLayout = true }
2191
+    }
2192
+
2193
+    private let ringLayer = CAShapeLayer()
2194
+    private var avatarRingMode = false
2195
+    private static let ringLineWidth: CGFloat = 2.25
2196
+
2197
+    override init(frame frameRect: NSRect) {
2198
+        super.init(frame: frameRect)
2199
+        wantsLayer = true
2200
+        layer?.masksToBounds = false
2201
+        ringLayer.fillColor = nil
2202
+        ringLayer.strokeColor = NSColor.clear.cgColor
2203
+        ringLayer.lineWidth = Self.ringLineWidth
2204
+        ringLayer.lineCap = .round
2205
+        ringLayer.opacity = 0
2206
+        ringLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
2207
+        layer?.insertSublayer(ringLayer, at: 0)
2208
+    }
2209
+
2210
+    @available(*, unavailable)
2211
+    required init?(coder: NSCoder) {
2212
+        nil
2213
+    }
2214
+
2215
+    func setAvatarRingMode(_ enabled: Bool) {
2216
+        avatarRingMode = enabled
2217
+        if enabled == false {
2218
+            ringLayer.removeAllAnimations()
2219
+            ringLayer.opacity = 0
2220
+            ringLayer.lineWidth = Self.ringLineWidth
2221
+        }
2222
+        needsLayout = true
2223
+    }
2224
+
2225
+    func updateRingAppearance(isDark: Bool, accent: NSColor) {
2226
+        let stroke = isDark
2227
+            ? accent.blended(withFraction: 0.22, of: NSColor.white) ?? accent
2228
+            : accent
2229
+        CATransaction.begin()
2230
+        CATransaction.setDisableActions(true)
2231
+        ringLayer.strokeColor = stroke.withAlphaComponent(0.95).cgColor
2232
+        CATransaction.commit()
2233
+    }
2234
+
2235
+    func setProfileHoverActive(_ active: Bool) {
2236
+        guard avatarRingMode else { return }
2237
+        ringLayer.removeAnimation(forKey: "pulse")
2238
+        if active {
2239
+            layoutRingPathIfNeeded()
2240
+            CATransaction.begin()
2241
+            CATransaction.setAnimationDuration(0.22)
2242
+            ringLayer.opacity = 1
2243
+            CATransaction.commit()
2244
+            let pulse = CABasicAnimation(keyPath: "lineWidth")
2245
+            pulse.fromValue = Self.ringLineWidth * 0.88
2246
+            pulse.toValue = Self.ringLineWidth * 1.45
2247
+            pulse.duration = 0.72
2248
+            pulse.autoreverses = true
2249
+            pulse.repeatCount = .infinity
2250
+            pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
2251
+            ringLayer.add(pulse, forKey: "pulse")
2252
+        } else {
2253
+            CATransaction.begin()
2254
+            CATransaction.setAnimationDuration(0.18)
2255
+            ringLayer.opacity = 0
2256
+            CATransaction.commit()
2257
+            ringLayer.lineWidth = Self.ringLineWidth
2258
+        }
2259
+    }
2260
+
2261
+    private func layoutRingPathIfNeeded() {
2262
+        guard avatarRingMode, let btn = authButton else { return }
2263
+        let f = btn.frame
2264
+        guard f.width > 1, f.height > 1 else { return }
2265
+        let center = CGPoint(x: f.midX, y: f.midY)
2266
+        let avatarR = min(f.width, f.height) / 2
2267
+        let gap: CGFloat = 3.5
2268
+        let ringRadius = avatarR + gap
2269
+        let d = ringRadius * 2
2270
+        CATransaction.begin()
2271
+        CATransaction.setDisableActions(true)
2272
+        ringLayer.bounds = CGRect(x: 0, y: 0, width: d, height: d)
2273
+        ringLayer.position = center
2274
+        ringLayer.path = CGPath(ellipseIn: CGRect(origin: .zero, size: CGSize(width: d, height: d)), transform: nil)
2275
+        CATransaction.commit()
2276
+    }
2277
+
2278
+    override func layout() {
2279
+        super.layout()
2280
+        layoutRingPathIfNeeded()
2281
+    }
2282
+}
2283
+
2167 2284
 /// Ensures `NSClickGestureRecognizer` on the row receives clicks instead of child label/image views swallowing them.
2168 2285
 private class RowHitTestView: NSView {
2169 2286
     override func hitTest(_ point: NSPoint) -> NSView? {
@@ -3238,6 +3355,14 @@ private extension ViewController {
3238 3355
         guard let button = scheduleGoogleAuthButton else { return }
3239 3356
 
3240 3357
         let profileName = scheduleCurrentProfile?.name ?? "Google account"
3358
+        let ringHostInset: CGFloat = signedIn ? 14 : 0
3359
+        scheduleGoogleAuthHostPadWidthConstraint?.constant = ringHostInset
3360
+        scheduleGoogleAuthHostPadHeightConstraint?.constant = ringHostInset
3361
+        scheduleGoogleAuthHostView?.setAvatarRingMode(signedIn)
3362
+        scheduleGoogleAuthHostView?.updateRingAppearance(isDark: darkModeEnabled, accent: palette.primaryBlue)
3363
+        if signedIn == false {
3364
+            scheduleGoogleAuthHostView?.setProfileHoverActive(false)
3365
+        }
3241 3366
 
3242 3367
         if signedIn {
3243 3368
             button.setAccessibilityLabel("\(profileName), Google account")
@@ -3360,7 +3485,14 @@ private extension ViewController {
3360 3485
 
3361 3486
     private func applyGoogleAuthButtonSurface() {
3362 3487
         guard let button = scheduleGoogleAuthButton else { return }
3488
+        let signedIn = (googleOAuth.loadTokens() != nil)
3363 3489
         let isDark = darkModeEnabled
3490
+        if signedIn {
3491
+            button.layer?.backgroundColor = NSColor.clear.cgColor
3492
+            button.layer?.borderWidth = 0
3493
+            scheduleGoogleAuthHostView?.updateRingAppearance(isDark: isDark, accent: palette.primaryBlue)
3494
+            return
3495
+        }
3364 3496
         let baseBackground = isDark
3365 3497
             ? NSColor(calibratedRed: 8.0 / 255.0, green: 14.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
3366 3498
             : NSColor.white
@@ -3372,6 +3504,7 @@ private extension ViewController {
3372 3504
         let hoverBorder = isDark
3373 3505
             ? NSColor(calibratedWhite: 0.62, alpha: 1)
3374 3506
             : NSColor(calibratedWhite: 0.56, alpha: 1)
3507
+        button.layer?.borderWidth = 1
3375 3508
         button.layer?.backgroundColor = (scheduleGoogleAuthHovering ? hoverBackground : baseBackground).cgColor
3376 3509
         button.layer?.borderColor = (scheduleGoogleAuthHovering ? hoverBorder : baseBorder).cgColor
3377 3510
     }