Greetings, traveler!
Liquid Glass is exposed through public APIs such as UIGlassEffect, but not every component Apple uses internally has a public equivalent.
One interesting example is _UILiquidLensView, a private UIKit view used to create an interactive glass lens that can move between a resting and lifted state. The class is not documented by Apple, but its Objective-C interface can be partially reconstructed from runtime usage found in open source projects.
This article looks at what we can learn from _UILiquidLensView, how its lifted state works, how Telegram uses it, and why this is still research-only code for most iOS applications.
What is _UILiquidLensView?
The leading underscore is the first warning:
_UILiquidLensViewUIKit uses this naming convention extensively for internal implementation types. The class exists at runtime, but it is not part of the public UIKit SDK.
Because there is no public declaration, Swift code cannot initialize it normally:
let lens = _UILiquidLensView()Instead, it can be resolved through the Objective-C runtime:
let viewClass = NSClassFromString("_UILiquidLensView")The same applies to its methods. Selectors can be created dynamically:
let selector = NSSelectorFromString(
"setLifted:animated:alongsideAnimations:completion:"
)This gives us access to an implementation that exists inside UIKit without importing a private header.
It does not make the API public. It only postpones the lookup until runtime.
Creating the lens
One initializer visible through existing implementations is:
initWithRestingBackground:A simplified Swift version looks like this:
guard
let lensClass =
NSClassFromString("_UILiquidLensView")
as AnyObject as? NSObjectProtocol,
let allocated =
lensClass
.perform(NSSelectorFromString("alloc"))?
.takeUnretainedValue(),
let instance =
allocated
.perform(
NSSelectorFromString("initWithRestingBackground:"),
with: UIView()
)?
.takeUnretainedValue() as? UIView
else {
return
}The object returned by UIKit is still a UIView, so normal view operations such as changing its bounds and center work without private API calls.
Resting and lifted states
The private API exposes a method with the selector:
setLifted:animated:alongsideAnimations:completion:The implementation used in Telegram calls it through an Objective-C function pointer with this shape:
typealias ObjCMethod = @convention(c) (
AnyObject,
Selector,
Bool,
Bool,
@escaping () -> Void,
(() -> Void)?
) -> VoidThe first Boolean switches between resting and lifted states. The second controls whether UIKit animates the transition. The additional block lets the caller change its own layout while UIKit performs the internal lens animation.
That last parameter makes the API particularly interesting. For example, the lens can grow while entering the lifted state:
function(
lensView,
selector,
true,
true,
{
lensView.bounds.size = CGSize(
width: 116,
height: 116
)
},
nil
)The result is not just a resized glass view. UIKit performs its own transition while the caller’s geometry changes alongside it.
This is close to the interaction seen in system controls where a selection appears to detach from its resting position before following the user’s finger.
Supplying content to the lens
Several other private selectors appear around _UILiquidLensView:
setLiftedContainerView:
setLiftedContentView:
setOverridePunchoutView:
setLiftedContentMode:
setStyle:
setWarpsContentBelow:A setup can look like this:
lensView.perform(
NSSelectorFromString("setLiftedContainerView:"),
with: containerView
)
lensView.perform(
NSSelectorFromString("setLiftedContentView:"),
with: selectedContentView
)
lensView.perform(
NSSelectorFromString("setOverridePunchoutView:"),
with: regularContentView
)The names give us useful hints, but they are still private implementation details. Apple does not document the exact contract of these views.
In practice, supplying separate regular and lifted content makes it possible to give the content under the lens a different appearance. A selected symbol, for example, can use another color while the rest of the menu remains unchanged.
There is also:
setWarpsContentBelow:which accepts a Boolean value:
typealias Method = @convention(c) (
AnyObject,
Selector,
Bool
) -> VoidSetting it to true enables behavior associated with the content underneath the lens.
Calling scalar private methods
Selectors taking objects are relatively easy to invoke:
object.perform(selector, with: value)Scalar parameters require a different approach.
let selector = NSSelectorFromString("setStyle:")
if let method = lensView.method(for: selector) {
typealias ObjCMethod = @convention(c) (
AnyObject,
Selector,
Int32
) -> Void
let function = unsafeBitCast(
method,
to: ObjCMethod.self
)
function(lensView, selector, 1)
}The same approach is used for setLiftedContentMode:.
Telegram uses the API
A useful real-world reference is Telegram’s public iOS repository.
Its LiquidLensView component resolves _UILiquidLensView dynamically on iOS 26:
if #available(iOS 26.0, *) {
if let viewClass =
NSClassFromString("_UILiquidLensView")
as AnyObject as? NSObjectProtocol {
let allocSelector = NSSelectorFromString("alloc")
let initSelector =
NSSelectorFromString("initWithRestingBackground:")
let objcAlloc =
viewClass
.perform(allocSelector)
.takeUnretainedValue()
let instance =
objcAlloc
.perform(
initSelector,
with: UIView()
)
.takeUnretainedValue()
self.lensView = instance as? UIView
}
}It then configures the lifted content, punchout view, content mode, style and content warping through private selectors.
When the lifted state changes, Telegram calls:
setLifted:animated:alongsideAnimations:completion:and changes the lens bounds inside the alongside animation block.
This is useful evidence for understanding the API, but it should not be interpreted as general permission to ship the same approach.
Can you use _UILiquidLensView in production?
For an App Store application, the answer is straightforward: you should not rely on it.
Apple’s App Review Guideline 2.5.1 says that apps may only use public APIs. _UILiquidLensView and the selectors discussed here are not public UIKit APIs.
Using:
NSClassFromString("_UILiquidLensView")instead of referencing the class directly does not change that. Likewise, constructing selector names dynamically does not turn methods such as:
setLifted:
setStyle:
setWarpsContentBelow:into supported APIs.
Runtime lookup can avoid a compile-time dependency, but the application is still invoking private UIKit functionality.
There is another practical problem even outside App Review: Apple makes no compatibility guarantees for private APIs. Any of these can change in a future release:
class name
initializer
selector names
argument types
numeric mode values
view hierarchy assumptions
animation behaviorA minor iOS update could change the implementation without a deprecation period or migration path.
Private API can still be useful for research, prototypes, reverse engineering, internal experiments, or understanding how system components behave. It should not become a dependency of normal App Store product code.
Apple’s current App Review Guidelines are available here.
Building a small Liquid Lens experiment
To explore the API, I created a circular menu containing three items. The outer surface uses the public Liquid Glass API:
let effect = UIGlassEffect(style: .regular)
effect.isInteractive = true
let glassView = UIVisualEffectView(effect: effect)Inside it are two copies of the menu content.
The first uses the normal appearance:
addTabItems(
to: itemsView,
color: .label
)The second uses a selected appearance:
addTabItems(
to: selectedItemsView,
color: .systemIndigo
)These views are then supplied to the private lens:
callObject(
instance,
selectorName: "setLiftedContentView:",
value: selectedItemsView
)
callObject(
instance,
selectorName: "setOverridePunchoutView:",
value: itemsView
)A zero-duration UILongPressGestureRecognizer gives us the interaction phases needed for dragging:
gesture.minimumPressDuration = 0
gesture.allowableMovement = .greatestFiniteMagnitudeWhen the gesture begins, the lens enters its lifted state. While the gesture changes, its center follows the finger. When the gesture ends, the nearest menu item becomes selected and the lens returns to its resting state.
The example also includes a simple public fallback, which is useful because a private class can disappear at any time.
Complete example
import SwiftUI
import UIKit
/// Research-only demo. `_UILiquidLensView` is private UIKit API and must not be
/// shipped in an App Store build.
struct LiquidLensDemo: View {
var body: some View {
ZStack {
LinearGradient(
colors: [
.indigo,
.cyan,
.orange.opacity(0.3),
.pink.opacity(0.7)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
Circle()
.fill(.white.opacity(0.2))
.frame(width: 260, height: 260)
.blur(radius: 12)
.offset(x: 120, y: -190)
VStack(spacing: 16) {
VStack(spacing: 8) {
Text("Circular Liquid Lens")
.font(.title.bold())
Text("Drag the lens around the circular menu")
.font(.subheadline)
.multilineTextAlignment(.center)
}
.foregroundStyle(.white)
.shadow(radius: 8)
Spacer()
PrivateLiquidLensTabBar()
.frame(width: 310, height: 310)
Text("Private API · for experimentation only")
.font(.caption)
.foregroundStyle(.white.opacity(0.85))
}
.padding()
}
}
}
private struct PrivateLiquidLensTabBar: UIViewRepresentable {
func makeUIView(context: Context) -> LiquidLensTabBarView {
LiquidLensTabBarView()
}
func updateUIView(
_ uiView: LiquidLensTabBarView,
context: Context
) {}
}
@MainActor
private final class LiquidLensTabBarView: UIView {
private let itemCount = 3
private let restingLensSize = CGSize(
width: 96,
height: 96
)
private let liftedLensSize = CGSize(
width: 116,
height: 116
)
private let tabs = [
(symbol: "bubble.left.fill", title: "Chats"),
(symbol: "person.2.fill", title: "Contacts"),
(symbol: "gearshape.fill", title: "Settings")
]
private let glassView: UIVisualEffectView
private let itemsView = UIView()
private let selectedItemsView = UIView()
private let statusLabel = UILabel()
private var lensView: UIView!
private var usesPrivateLens = false
private var selectedIndex = 0
override init(frame: CGRect) {
let effect = UIGlassEffect(style: .regular)
effect.isInteractive = true
glassView = UIVisualEffectView(effect: effect)
super.init(frame: frame)
configureView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func configureView() {
backgroundColor = .clear
glassView.clipsToBounds = true
addSubview(glassView)
itemsView.isUserInteractionEnabled = false
addTabItems(
to: itemsView,
color: .label
)
selectedItemsView.isUserInteractionEnabled = false
addTabItems(
to: selectedItemsView,
color: .systemIndigo
)
glassView.contentView.addSubview(selectedItemsView)
statusLabel.font = .systemFont(
ofSize: 9,
weight: .semibold
)
statusLabel.textAlignment = .center
statusLabel.textColor = .secondaryLabel
statusLabel.isUserInteractionEnabled = false
glassView.contentView.addSubview(statusLabel)
lensView = makeLensView()
lensView.isUserInteractionEnabled = false
lensView.layer.zPosition = 10
glassView.contentView.addSubview(lensView)
glassView.contentView.addSubview(itemsView)
let gesture = UILongPressGestureRecognizer(
target: self,
action: #selector(handleDrag(_:))
)
gesture.minimumPressDuration = 0
gesture.allowableMovement = .greatestFiniteMagnitude
addGestureRecognizer(gesture)
isAccessibilityElement = true
accessibilityLabel = "Circular Liquid Lens menu"
accessibilityHint =
"Drag around the circle to change the selected item"
}
private func addTabItems(
to container: UIView,
color: UIColor
) {
for tab in tabs {
container.addSubview(
CircularMenuItemView(
symbolName: tab.symbol,
title: tab.title,
color: color
)
)
}
}
private func makeLensView() -> UIView {
let restingBackground = UIView()
restingBackground.backgroundColor =
UIColor.white.withAlphaComponent(0.38)
restingBackground.layer.borderColor =
UIColor.white.withAlphaComponent(0.55).cgColor
restingBackground.layer.borderWidth = 1
guard
let lensClass =
NSClassFromString("_UILiquidLensView")
as AnyObject as? NSObjectProtocol,
let allocated =
lensClass
.perform(NSSelectorFromString("alloc"))?
.takeUnretainedValue(),
let instance =
allocated
.perform(
NSSelectorFromString(
"initWithRestingBackground:"
),
with: restingBackground
)?
.takeUnretainedValue() as? UIView
else {
statusLabel.text = "Public fallback"
return makeFallbackLens()
}
usesPrivateLens = true
statusLabel.text = "Private UIKit lens"
callObject(
instance,
selectorName: "setLiftedContainerView:",
value: glassView.contentView
)
callObject(
instance,
selectorName: "setLiftedContentView:",
value: selectedItemsView
)
callObject(
instance,
selectorName: "setOverridePunchoutView:",
value: itemsView
)
callInt32(
instance,
selectorName: "setLiftedContentMode:",
value: 1
)
callInt32(
instance,
selectorName: "setStyle:",
value: 1
)
callBool(
instance,
selectorName: "setWarpsContentBelow:",
value: true
)
instance.setValue(
UIColor.white.withAlphaComponent(0.38),
forKey: "restingBackgroundColor"
)
return instance
}
private func makeFallbackLens() -> UIView {
let view = UIView()
view.backgroundColor =
UIColor.white.withAlphaComponent(0.3)
view.layer.borderColor =
UIColor.white.withAlphaComponent(0.55).cgColor
view.layer.borderWidth = 1
view.layer.cornerCurve = .continuous
view.layer.cornerRadius =
restingLensSize.height * 0.5
return view
}
override func layoutSubviews() {
super.layoutSubviews()
let diameter = min(
bounds.width,
bounds.height
)
glassView.frame = CGRect(
x: bounds.midX - diameter * 0.5,
y: bounds.midY - diameter * 0.5,
width: diameter,
height: diameter
)
glassView.layer.cornerRadius =
diameter * 0.5
itemsView.frame = glassView.bounds
selectedItemsView.frame = glassView.bounds
layoutMenuItems(in: itemsView)
layoutMenuItems(in: selectedItemsView)
statusLabel.frame = CGRect(
x: 0,
y: diameter - 27,
width: diameter,
height: 12
)
if lensView.bounds.size == .zero {
lensView.bounds = CGRect(
origin: .zero,
size: restingLensSize
)
lensView.center =
centerForItem(at: selectedIndex)
}
}
@objc
private func handleDrag(
_ gesture: UILongPressGestureRecognizer
) {
let location =
gesture.location(
in: glassView.contentView
)
switch gesture.state {
case .began:
setLifted(
true,
animated: true
)
moveLens(to: location)
case .changed:
moveLens(to: location)
case .ended:
selectedIndex =
nearestItemIndex(to: location)
settleLensAndLower()
case .cancelled, .failed:
settleLensAndLower()
default:
break
}
}
private func moveLens(to point: CGPoint) {
let menuCenter = CGPoint(
x: itemsView.bounds.midX,
y: itemsView.bounds.midY
)
let offset = CGVector(
dx: point.x - menuCenter.x,
dy: point.y - menuCenter.y
)
let distance = hypot(
offset.dx,
offset.dy
)
let maximumDistance =
min(
itemsView.bounds.width,
itemsView.bounds.height
) * 0.5
- liftedLensSize.width * 0.5
- 10
guard
distance > maximumDistance,
distance > 0
else {
lensView.center = point
return
}
let scale =
maximumDistance / distance
lensView.center = CGPoint(
x: menuCenter.x + offset.dx * scale,
y: menuCenter.y + offset.dy * scale
)
}
private func settleLensAndLower() {
UIView.animate(
withDuration: 0.28,
delay: 0,
usingSpringWithDamping: 0.72,
initialSpringVelocity: 0,
options: [
.beginFromCurrentState,
.allowUserInteraction
]
) {
self.lensView.center =
self.centerForItem(
at: self.selectedIndex
)
}
setLifted(
false,
animated: true
)
accessibilityValue =
"Tab \(selectedIndex + 1) of \(itemCount)"
}
private func centerForItem(
at index: Int
) -> CGPoint {
let angles: [CGFloat] = [
-.pi / 2,
.pi * 5 / 6,
.pi / 6
]
let menuCenter = CGPoint(
x: itemsView.bounds.midX,
y: itemsView.bounds.midY - 5
)
let radius =
min(
itemsView.bounds.width,
itemsView.bounds.height
) * 0.27
let angle = angles[index]
return CGPoint(
x: menuCenter.x + cos(angle) * radius,
y: menuCenter.y + sin(angle) * radius
)
}
private func layoutMenuItems(
in container: UIView
) {
for (index, itemView)
in container.subviews.enumerated() {
itemView.bounds = CGRect(
x: 0,
y: 0,
width: 104,
height: 62
)
itemView.center =
centerForItem(at: index)
}
}
private func nearestItemIndex(
to point: CGPoint
) -> Int {
(0..<itemCount).min { lhs, rhs in
squaredDistance(
from: point,
to: centerForItem(at: lhs)
)
<
squaredDistance(
from: point,
to: centerForItem(at: rhs)
)
} ?? selectedIndex
}
private func squaredDistance(
from lhs: CGPoint,
to rhs: CGPoint
) -> CGFloat {
let dx = lhs.x - rhs.x
let dy = lhs.y - rhs.y
return dx * dx + dy * dy
}
private func setLifted(
_ lifted: Bool,
animated: Bool
) {
let targetSize =
lifted
? liftedLensSize
: restingLensSize
guard usesPrivateLens else {
UIView.animate(
withDuration:
animated ? 0.25 : 0
) {
self.lensView.bounds.size =
targetSize
self.lensView.layer.cornerRadius =
targetSize.height * 0.5
}
return
}
let selector = NSSelectorFromString(
"setLifted:animated:alongsideAnimations:completion:"
)
guard
lensView.responds(to: selector),
let method =
lensView.method(for: selector)
else {
return
}
typealias Method = @convention(c) (
AnyObject,
Selector,
Bool,
Bool,
@escaping () -> Void,
(() -> Void)?
) -> Void
let function =
unsafeBitCast(
method,
to: Method.self
)
function(
lensView,
selector,
lifted,
animated,
{ [weak self] in
guard let self else {
return
}
self.lensView.bounds.size =
targetSize
},
nil
)
}
private func callObject(
_ object: NSObject,
selectorName: String,
value: AnyObject
) {
let selector =
NSSelectorFromString(selectorName)
guard
object.responds(to: selector)
else {
return
}
object.perform(
selector,
with: value
)
}
private func callInt32(
_ object: NSObject,
selectorName: String,
value: Int32
) {
let selector =
NSSelectorFromString(selectorName)
guard
object.responds(to: selector),
let method =
object.method(for: selector)
else {
return
}
typealias Method =
@convention(c) (
AnyObject,
Selector,
Int32
) -> Void
unsafeBitCast(
method,
to: Method.self
)(
object,
selector,
value
)
}
private func callBool(
_ object: NSObject,
selectorName: String,
value: Bool
) {
let selector =
NSSelectorFromString(selectorName)
guard
object.responds(to: selector),
let method =
object.method(for: selector)
else {
return
}
typealias Method =
@convention(c) (
AnyObject,
Selector,
Bool
) -> Void
unsafeBitCast(
method,
to: Method.self
)(
object,
selector,
value
)
}
}
@MainActor
private final class CircularMenuItemView: UIView {
private let imageView: UIImageView
private let titleLabel = UILabel()
init(
symbolName: String,
title: String,
color: UIColor
) {
imageView = UIImageView(
image: UIImage(
systemName: symbolName
)
)
super.init(frame: .zero)
isUserInteractionEnabled = false
imageView.contentMode = .center
imageView.tintColor = color
imageView.preferredSymbolConfiguration =
UIImage.SymbolConfiguration(
pointSize: 23,
weight: .semibold
)
addSubview(imageView)
titleLabel.text = title
titleLabel.font =
.systemFont(
ofSize: 13,
weight: .semibold
)
titleLabel.textColor = color
titleLabel.textAlignment = .center
addSubview(titleLabel)
}
required init?(coder: NSCoder) {
fatalError(
"init(coder:) has not been implemented"
)
}
override func layoutSubviews() {
super.layoutSubviews()
imageView.frame = CGRect(
x: 0,
y: 0,
width: bounds.width,
height: 34
)
titleLabel.frame = CGRect(
x: 0,
y: 39,
width: bounds.width,
height: 20
)
}
}
#Preview {
LiquidLensDemo()
}The example intentionally accesses _UILiquidLensView through the Objective-C runtime and falls back to a normal UIView when the private class cannot be created.
That fallback is useful for an experiment, but it does not make private API suitable for production. The point of the project is to inspect the behavior of UIKit’s internal lens and understand how its lifted interaction is assembled.
The demo code is also available on GitHub.
