Exploring _UILiquidLensView


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:

_UILiquidLensView

UIKit 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)?
) -> Void

The 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
) -> Void

Setting 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 behavior

A 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 = .greatestFiniteMagnitude

When 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.