Efficient off-screen UIView rendering and mirroring
Asked Answered
V

2

9

I have a "off-screen" UIView hierarchy which I want render in different locations of my screen. In addition it should be possible to show only parts of this view hierarchy and should reflect all changes made to this hierarchy.

The difficulties:

  • The UIView method drawHierarchy(in:afterScreenUpdates:) always calls draw(_ rect:) and is therefore very inefficient for large hierarchies if you want to incorporate all changes to the view hierarchy. You would have to redraw it every screen update or observe all changing properties of all views. Draw view hierarchy documentation
  • The UIView method snapshotView(afterScreenUpdates:) also does not help much since I have not found a way to get a correct view hierarchy drawing if this hierarchy is "off-screen". Snapshot view documentation

"Off-Screen": The root view of this view hierarchy is not part of the UI of the app. It has no superview.

Below you can see a visual representation of my idea:

example

Vulgarity answered 20/1, 2019 at 13:3 Comment(12)
It may involve layout constrains which make the offscreen very hard unless you can access uikit pipelines.Amoeboid
@Amoeboid What about using no layout constraints?Vulgarity
You may invent another simple view class by yourself . Using CGLAYER as much as possible. Typical you just need a Uiview, and many Drawing functions. Just don’t know how complicated the drawings are.Amoeboid
Actually, you can use drawHierarchy(in:afterScreenUpdates:) multiple times to collect the static images to produce CGLAYERS. Use a dictionary to build the layers from bottom to top. Let's say only one layer is dynamic. To compose such a view just need to draw three layers :(static bottom, dynamic layer, static top) which will be much faster than calling drawHierarchy(in:afterScreenUpdates:). Hope you can get it.Amoeboid
@Amoeboid How can I then synchronize the dynamic properties of a view? In the example above the "Button" changes "Controlled Element" in some way. This I would have to observe and then draw the view hierarchy if something has changed. However I would have to observe all properties which is very inefficient I think. (How would I even do that?)Vulgarity
Here is similar idea. It's just hard to think and easy to implement as it's just transversing a tree nodes. Every nodes has a cached layer. You may determine if you need to draw it or updating it. You can skip many nodes if you don't need to update or draw it. Anyway if UIKit does not provide such function, you have to do something to speed up.Amoeboid
Should your button still be responsive ? If it should, all the "draw" solutions can be removedAldershot
Have you tried CAReplicatorLayer? It sort of does what you are asking.Castled
@GaétanZ It would be great if the button is responsive but I didn’t even manage to draw the view multiple times.Vulgarity
@Castled I didn’t try it since one of the huge limitations is that you cannot place the individual replicated layers anywhere you want (as far as I know).Vulgarity
@Vulgarity Duplicating this hierarchy and applying different CGAffineTransform to them is not an acceptable solution ? They could all observe the changes and update themself accordingly.Aldershot
@GaétanZ How can one duplicate the hierarchy and also synchronize all changes? If you have a good (or idea for an) implementation please let me (us) know it as an answer.Vulgarity
V
0

Here's how I would go about doing it. First, I would duplicate the view you are trying to duplicate. I wrote a little extension for this:

extension UIView {
    func duplicate<T: UIView>() -> T {
        return NSKeyedUnarchiver.unarchiveObject(with: NSKeyedArchiver.archivedData(withRootObject: self)) as! T
    }

    func copyProperties(fromView: UIView, recursive: Bool = true) {
        contentMode = fromView.contentMode
        tag = fromView.tag

        backgroundColor = fromView.backgroundColor
        tintColor = fromView.tintColor

        layer.cornerRadius = fromView.layer.cornerRadius
        layer.maskedCorners = fromView.layer.maskedCorners

        layer.borderColor = fromView.layer.borderColor
        layer.borderWidth = fromView.layer.borderWidth

        layer.shadowOpacity = fromView.layer.shadowOpacity
        layer.shadowRadius = fromView.layer.shadowRadius
        layer.shadowPath = fromView.layer.shadowPath
        layer.shadowColor = fromView.layer.shadowColor
        layer.shadowOffset = fromView.layer.shadowOffset

        clipsToBounds = fromView.clipsToBounds
        layer.masksToBounds = fromView.layer.masksToBounds
        mask = fromView.mask
        layer.mask = fromView.layer.mask

        alpha = fromView.alpha
        isHidden = fromView.isHidden
        if let gradientLayer = layer as? CAGradientLayer, let fromGradientLayer = fromView.layer as? CAGradientLayer {
            gradientLayer.colors = fromGradientLayer.colors
            gradientLayer.startPoint = fromGradientLayer.startPoint
            gradientLayer.endPoint = fromGradientLayer.endPoint
            gradientLayer.locations = fromGradientLayer.locations
            gradientLayer.type = fromGradientLayer.type
        }

        if let imgView = self as? UIImageView, let fromImgView = fromView as? UIImageView {
            imgView.tintColor = .clear
            imgView.image = fromImgView.image?.withRenderingMode(fromImgView.image?.renderingMode ?? .automatic)
            imgView.tintColor = fromImgView.tintColor
        }

        if let btn = self as? UIButton, let fromBtn = fromView as? UIButton {
            btn.setImage(fromBtn.image(for: fromBtn.state), for: fromBtn.state)
        }

        if let textField = self as? UITextField, let fromTextField = fromView as? UITextField {
            if let leftView = fromTextField.leftView {
                textField.leftView = leftView.duplicate()
                textField.leftView?.copyProperties(fromView: leftView)
            }

            if let rightView = fromTextField.rightView {
                textField.rightView = rightView.duplicate()
                textField.rightView?.copyProperties(fromView: rightView)
            }

            textField.attributedText = fromTextField.attributedText
            textField.attributedPlaceholder = fromTextField.attributedPlaceholder
        }

        if let lbl = self as? UILabel, let fromLbl = fromView as? UILabel {
            lbl.attributedText = fromLbl.attributedText
            lbl.textAlignment = fromLbl.textAlignment
            lbl.font = fromLbl.font
            lbl.bounds = fromLbl.bounds
        }

       if recursive {
            for (i, view) in subviews.enumerated() {
                if i >= fromView.subviews.count {
                    break
                }

                view.copyProperties(fromView: fromView.subviews[i])
            }
        }
    }
}

to use this extension, simply do

let duplicateView = originalView.duplicate()
duplicateView.copyProperties(fromView: originalView)
parentView.addSubview(duplicateView)

Then I would mask the duplicate view to only get the particular section that you want

let mask = UIView(frame: CGRect(x: 0, y: 0, width: yourNewWidth, height: yourNewHeight))
mask.backgroundColor = .black
duplicateView.mask = mask

finally, I would scale it to whatever size you want using CGAffineTransform

duplicateView.transform = CGAffineTransform(scaleX: xScale, y: yScale)

the copyProperties function should work well but you can change it if necessary to copy even more things from one view to another.

Good luck, let me know how it goes :)

Voice answered 24/2, 2019 at 21:50 Comment(2)
What about observing changes to the original view which was duplicated? E.g. changing the background color or the view hierarchy?Vulgarity
You can add an observer to the original view and change the duplicate view's background color accordingly. I would just perform the same UI changes that you make on the original view on the duplicate view. I know this doesn't sound like a very good way of doing it but unfortunately, I don't think there's an easier way. You can also add tags to various subviews in that you know you will change eg. set the tag of the textfield to 62 and then edit the view in the duplicate view that has a tag of 62Voice
C
0

I'd duplicate the content I wish to display and crop it as I want.

Let's say I have a ContentViewController which carries the view hierarchy I wish to replicate. I would encapsule all the changes that can be made to the hierarchy inside a ContentViewModel. Something like:

struct ContentViewModel {
    let actionTitle: String?
    let contentMessage: String?
    // ...
}

class ContentViewController: UIViewController {
    func display(_ viewModel: ContentViewModel) { /* ... */ }
}

With a ClippingView (or a simple UIScrollView) :

class ClippingView: UIView {

    var contentOffset: CGPoint = .zero // a way to specify the part of the view you wish to display
    var contentFrame: CGRect = .zero // the actual size of the clipped view

    var clippedView: UIView?

    override init(frame: CGRect) {
        super.init(frame: frame)
        clipsToBounds = true
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        clippedView?.frame = contentFrame
        clippedView?.frame.origin = contentOffset
    }
}

And a view controller container, I would crop each instance of my content and update all of them each time something happens :

class ContainerViewController: UIViewController {

    let contentViewControllers: [ContentViewController] = // 3 in your case

    override func viewDidLoad() {
        super.viewDidLoad()
        contentViewControllers.forEach { viewController in
             addChil(viewController)
             let clippingView = ClippingView()
             clippingView.clippedView = viewController.view
             clippingView.contentOffset = // ...
             viewController.didMove(to: self)
        }
    }

    func somethingChange() {
        let newViewModel = ContentViewModel(...)
        contentViewControllers.forEach { $0.display(newViewModel) }
    }
} 

Could this scenario work in your case ?

Crissman answered 25/2, 2019 at 18:27 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.