iOS PDFKit: make Text Widget PDFAnnotation readonly - ios

I would like to make the Text Widget PDFAnnotation readonly. I tried to set the isReadOnly flag to true, but it doesn't seem to make any difference. The user is still able to edit the annotation after tapping it.

I have been working on this problem for a while now and I finally found something that works. The solution for me was to use a .widget over .freeText. This method keeps the text from being selected and modified after export. I should point out that PDFs are not infallible, decompilation is possible with any PDF but for the every day office worker this is a perfect solution.
//Don't use this - This is what most tutorials show
//Which can be easily changed after export
let annotation = PDFAnnotation(bounds: CGRect(x: 10 , y: 720 , width: 100, height: 50), forType: .freeText, withProperties: nil)
Use this - Swift 5
func addText(x: Int, y: Int, width: Int, height: Int, text: String, fontSize: CGFloat, centerText: Bool){
//I'm using a PDFView but, if you are not displaying the PDF in the app then just use
//let fileURL = Bundle.main.url(forResource: "MyPDF", withExtension: "pdf")!
//let pdfDocument = PDFDocument(url: fileURL)
//guard let document = pdfDocument else { return }
guard let document = pdfView.document else { return }
//I'm only using one page for my PDF but this is easy to change
let lastPage = document.page(at: document.pageCount - 1)
let annotation = PDFAnnotation(bounds: CGRect(x: x , y: y, width: width, height: height), forType: .widget, withProperties: nil)
//Don't use contents and caption
//annotation.contents = text
//annotation.caption = text
//Use this instead
annotation.widgetFieldType = .text
annotation.widgetStringValue = text
//Check if the text should be centered
if (centerText){ annotation.alignment = .center }
//I'm using a custom font
annotation.font = UIFont(name: "calibri", size: fontSize)
//You can use this instead
//annotation.font = UIFont.systemFont(ofSize: fontSize)
//Set the color
annotation.fontColor = .black
annotation.color = .clear
annotation.backgroundColor = .clear
//This is useless
annotation.isReadOnly = true
//Add the annotation to the last page
lastPage?.addAnnotation(annotation)
}

It seems to be a bug/oversight that PDFKit doesn't honor the isReadOnly attribute on annotations. However I was able to work around this by adding a blank annotation over other annotations in the document. I added a makeReadOnly() extension to PDF document that does this for all annotations to make the whole document read only. Here's the code:
// A blank annotation that does nothing except serve to block user input
class BlockInputAnnotation: PDFAnnotation {
init(forBounds bounds: CGRect, withProperties properties: [AnyHashable : Any]?) {
super.init(bounds: bounds, forType: PDFAnnotationSubtype.stamp, withProperties: properties)
self.fieldName = "blockInput"
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(with box: PDFDisplayBox, in context: CGContext) {
}
}
extension PDFDocument {
func makeReadOnly() {
for pageNumber in 0..<self.pageCount {
guard let page = self.page(at: pageNumber) else {
continue
}
for annotation in page.annotations {
annotation.isReadOnly = true // This _should_ be enough, but PDFKit doesn't recognize the isReadOnly attribute
// So we add a blank annotation on top of the annotation, and it will capture touch/mouse events
let blockAnnotation = BlockInputAnnotation(forBounds: annotation.bounds, withProperties: nil)
blockAnnotation.isReadOnly = true
page.addAnnotation(blockAnnotation)
}
}
}
}

Related

How can I append UIView to UITextView?

I have big problem. I want add few UIView to text (UITextView, UILabel)
Each view contains ImageView with corner radius and text.
I want have result like this:
sample from Android
I tried:
Add image with text by NSMutableAttributedString. In this case I can't add corner radius. And all images are from external serwers so it's problem with add to text.
I tried this library: SubviewAttachingTextView. In this case when I added multiple items all items were stacked on top of each other.
Finaly I used WKWebView and I inject HTML with CSS to WebView. But in this solution I have problem with fit content to frame size and is very slow. (for me is the worst solution)
Does anyone have an idea how to develop? Maybe there are some mechanisms in SwiftUI?
You can create a Custom class for a UIView and add subviews that you need inside that, and you can call that class when ever you want, in SwiftUI you can add this very easily by implementing a Label element inside the stack.
I solved my problem. I used NSMutableAttributedString and extensions on UIImage (download images and making circular avatars)
extension UIImage {
convenience init?(withContentsOfUrl url: URL) throws {
let imageData = try Data(contentsOf: url)
self.init(data: imageData)
}
public func withRoundedCorners(radius: CGFloat? = nil) -> UIImage? {
let maxRadius = min(size.width, size.height) / 2
let cornerRadius: CGFloat
if let radius = radius, radius > 0 && radius <= maxRadius {
cornerRadius = radius
} else {
cornerRadius = maxRadius
}
UIGraphicsBeginImageContextWithOptions(size, false, scale)
let rect = CGRect(origin: .zero, size: size)
UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).addClip()
draw(in: rect)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return image
}
}
and my sample code:
class NewNotificationsViewController: UIViewController, UITextViewDelegate {
#IBOutlet weak var testText: UITextView!
override func viewDidLoad() {
super.viewDidLoad()
self.testText.delegate = self
let url = "image-url";
let font = UIFont.systemFont(ofSize: 22)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: UIColor.orange,
.link: "http://test.pl"
]
let myString = NSMutableAttributedString(string: "Text at the beginning ", attributes: attributes)
let imageAttachment = NSTextAttachment()
do {
imageAttachment.image = try UIImage.init(withContentsOfUrl: URL(string: url)!)
} catch {
imageAttachment.image = UIImage(named: "avatar_k")
}
imageAttachment.image = imageAttachment.image?.withRoundedCorners()
imageAttachment.bounds = CGRect(x: 0, y: -8, width: 32, height: 32)
let imageString = NSAttributedString(attachment: imageAttachment)
myString.append(imageString)
myString.append(NSAttributedString(string: " THE END!!!", attributes: attributes))
self.testText.attributedText = myString
}
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange) -> Bool {
print("DEBUG", URL.absoluteString)
return false
}
}

Attributed text with round corners [duplicate]

I have a question regarding rounded corners and text background color for a custom UIView.
Basically, I need to achieve an effect like this (image attached - notice the rounded corners on one side) in a custom UIView:
I'm thinking the approach to use is:
Use Core Text to get glyph runs.
Check highlight range.
If the current run is within the highlight range, draw a background rectangle with rounded corners and desired fill color before drawing the glyph run.
Draw the glyph run.
However, I'm not sure whether this is the only solution (or for that matter, whether this is the most efficient solution).
Using a UIWebView is not an option, so I have to do it in a custom UIView.
My question being, is this the best approach to use, and am I on the right track? Or am I missing out something important or going about it the wrong way?
TL;DR; Create a custom-view, which renders same old NSAttributedString, but with rounded-corners.
Unlike Android's SpannableString, iOS does not support "custom-render for custom-string-attributes", at least not without an entire custom-view (at time of writing, 2022).
I managed to achieve the above effect, so thought I'd post an answer for the same.
If anyone has any suggestions about making this more effective, please feel free to contribute. I'll be sure to mark your answer as the correct one. :)
For doing this, you'll need to add a "custom attribute" to NSAttributedString.
Basically, what that means is that you can add any key-value pair, as long as it is something that you can add to an NSDictionary instance. If the system does not recognize that attribute, it does nothing. It is up to you, as the developer, to provide a custom implementation and behavior for that attribute.
For the purposes of this answer, let us assume I've added a custom attribute called: #"MyRoundedBackgroundColor" with a value of [UIColor greenColor].
For the steps that follow, you'll need to have a basic understanding of how CoreText gets stuff done. Check out Apple's Core Text Programming Guide for understanding what's a frame/line/glyph run/glyph, etc.
So, here are the steps:
Create a custom UIView subclass.
Have a property for accepting an NSAttributedString.
Create a CTFramesetter using that NSAttributedString instance.
Override the drawRect: method
Create a CTFrame instance from the CTFramesetter.
You will need to give a CGPathRef to create the CTFrame. Make that CGPath to be the same as the frame in which you wish to draw the text.
Get the current graphics context and flip the text coordinate system.
Using CTFrameGetLines(...), get all the lines in the CTFrame you just created.
Using CTFrameGetLineOrigins(...), get all the line origins for the CTFrame.
Start a for loop - for each line in the array of CTLine...
Set the text position to the start of the CTLine using CGContextSetTextPosition(...).
Using CTLineGetGlyphRuns(...) get all the Glyph Runs (CTRunRef) from the CTLine.
Start another for loop - for each glyphRun in the array of CTRun...
Get the range of the run using CTRunGetStringRange(...).
Get typographic bounds using CTRunGetTypographicBounds(...).
Get the x offset for the run using CTLineGetOffsetForStringIndex(...).
Calculate the bounding rect (let's call it runBounds) using the values returned from the aforementioned functions.
Remember - CTRunGetTypographicBounds(...) requires pointers to variables to store the "ascent" and "descent" of the text. You need to add those to get the run height.
Get the attributes for the run using CTRunGetAttributes(...).
Check if the attribute dictionary contains your attribute.
If your attribute exists, calculate the bounds of the rectangle that needs to be painted.
Core text has the line origins at the baseline. We need to draw from the lowermost point of the text to the topmost point. Thus, we need to adjust for descent.
So, subtract the descent from the bounding rect that we calculated in step 16 (runBounds).
Now that we have the runBounds, we know what area we want to paint - now we can use any of the CoreGraphis/UIBezierPath methods to draw and fill a rect with specific rounded corners.
UIBezierPath has a convenience class method called bezierPathWithRoundedRect:byRoundingCorners:cornerRadii: that let's you round specific corners. You specify the corners using bit masks in the 2nd parameter.
Now that you've filled the rect, simply draw the glyph run using CTRunDraw(...).
Celebrate victory for having created your custom attribute - drink a beer or something! :D
Regarding detecting that the attribute range extends over multiple runs, you can get the entire effective range of your custom attribute when the 1st run encounters the attribute. If you find that the length of the maximum effective range of your attribute is greater than the length of your run, you need to paint sharp corners on the right side (for a left to right script). More math will let you detect the highlight corner style for the next line as well. :)
Attached is a screenshot of the effect. The box on the top is a standard UITextView, for which I've set the attributedText. The box on the bottom is the one that has been implemented using the above steps. The same attributed string has been set for both the textViews.
Again, if there is a better approach than the one that I've used, please do let me know! :D
Hope this helps the community. :)
Cheers!
Just customize NSLayoutManager and override drawUnderline(forGlyphRange:underlineType:baselineOffset:lineFragmentRect:lineFragmentGlyphRange:containerOrigin:) Apple API Document
In this method, you can draw underline by yourself, Swift code,
override func drawUnderline(forGlyphRange glyphRange: NSRange,
underlineType underlineVal: NSUnderlineStyle,
baselineOffset: CGFloat,
lineFragmentRect lineRect: CGRect,
lineFragmentGlyphRange lineGlyphRange: NSRange,
containerOrigin: CGPoint
) {
let firstPosition = location(forGlyphAt: glyphRange.location).x
let lastPosition: CGFloat
if NSMaxRange(glyphRange) < NSMaxRange(lineGlyphRange) {
lastPosition = location(forGlyphAt: NSMaxRange(glyphRange)).x
} else {
lastPosition = lineFragmentUsedRect(
forGlyphAt: NSMaxRange(glyphRange) - 1,
effectiveRange: nil).size.width
}
var lineRect = lineRect
let height = lineRect.size.height * 3.5 / 4.0 // replace your under line height
lineRect.origin.x += firstPosition
lineRect.size.width = lastPosition - firstPosition
lineRect.size.height = height
lineRect.origin.x += containerOrigin.x
lineRect.origin.y += containerOrigin.y
lineRect = lineRect.integral.insetBy(dx: 0.5, dy: 0.5)
let path = UIBezierPath(rect: lineRect)
// let path = UIBezierPath(roundedRect: lineRect, cornerRadius: 3)
// set your cornerRadius
path.fill()
}
Then construct your NSAttributedString and add attributes .underlineStyle and .underlineColor.
addAttributes(
[
.foregroundColor: UIColor.white,
.underlineStyle: NSUnderlineStyle.single.rawValue,
.underlineColor: UIColor(red: 51 / 255.0, green: 154 / 255.0, blue: 1.0, alpha: 1.0)
],
range: range
)
That's it!
I did it by checking frames of text fragments. In my project I needed to highlight hashtags while a user is typing text.
class HashtagTextView: UITextView {
let hashtagRegex = "#[-_0-9A-Za-z]+"
private var cachedFrames: [CGRect] = []
private var backgrounds: [UIView] = []
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
configureView()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configureView()
}
override func layoutSubviews() {
super.layoutSubviews()
// Redraw highlighted parts if frame is changed
textUpdated()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
#objc private func textUpdated() {
// You can provide whatever ranges needed to be highlighted
let ranges = resolveHighlightedRanges()
let frames = ranges.compactMap { frame(ofRange: $0) }.reduce([], +)
if cachedFrames != frames {
cachedFrames = frames
backgrounds.forEach { $0.removeFromSuperview() }
backgrounds = cachedFrames.map { frame in
let background = UIView()
background.backgroundColor = UIColor.hashtagBackground
background.frame = frame
background.layer.cornerRadius = 5
insertSubview(background, at: 0)
return background
}
}
}
/// General setup
private func configureView() {
NotificationCenter.default.addObserver(self, selector: #selector(textUpdated), name: UITextView.textDidChangeNotification, object: self)
}
/// Looks for locations of the string to be highlighted.
/// The current case - ranges of hashtags.
private func resolveHighlightedRanges() -> [NSRange] {
guard text != nil, let regex = try? NSRegularExpression(pattern: hashtagRegex, options: []) else { return [] }
let matches = regex.matches(in: text, options: [], range: NSRange(text.startIndex..<text.endIndex, in: text))
let ranges = matches.map { $0.range }
return ranges
}
}
There is also a helper extension to determine frames of ranges:
extension UITextView {
func convertRange(_ range: NSRange) -> UITextRange? {
let beginning = beginningOfDocument
if let start = position(from: beginning, offset: range.location), let end = position(from: start, offset: range.length) {
let resultRange = textRange(from: start, to: end)
return resultRange
} else {
return nil
}
}
func frame(ofRange range: NSRange) -> [CGRect]? {
if let textRange = convertRange(range) {
let rects = selectionRects(for: textRange)
return rects.map { $0.rect }
} else {
return nil
}
}
}
Result text view:
I wrote the below code following the #codeBearer answer.
import UIKit
class CustomAttributedTextView: UITextView {
override func layoutSubviews() {
super.layoutSubviews()
}
func clearForReuse() {
setNeedsDisplay()
}
var lineCountUpdate: ((Bool) -> Void)?
override func draw(_ rect: CGRect) {
super.draw(rect)
UIColor.clear.setFill()
UIColor.clear.setFill()
guard let context = UIGraphicsGetCurrentContext() else { return }
context.textMatrix = .identity
context.translateBy(x: 0, y: bounds.size.height)
context.scaleBy(x: 1.0, y: -1.0)
let path = CGMutablePath()
let size = sizeThatFits(CGSize(width: self.frame.width, height: .greatestFiniteMagnitude))
path.addRect(CGRect(x: 0, y: 0, width: size.width, height: size.height), transform: .identity)
let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString)
let frame: CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attributedText.length), path, nil)
let lines: [CTLine] = frame.lines
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
for lineIndex in 0..<lines.count {
let line = lines[lineIndex]
let runs: [CTRun] = line.ctruns
var tagCountInOneLine = 0
for run in runs {
var cornerRadius: CGFloat = 3
let attributes: NSDictionary = CTRunGetAttributes(run)
var imgBounds: CGRect = .zero
if let value: UIColor = attributes.value(forKey: NSAttributedString.Key.customBackgroundColor.rawValue) as? UIColor {
var ascent: CGFloat = 0
imgBounds.size.width = CGFloat(CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, nil, nil) + 4)
imgBounds.size.height = ascent + 6
let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil)
imgBounds.origin.x = origins[lineIndex].x + xOffset + 3
imgBounds.origin.y = origins[lineIndex].y - 13
if lineIndex != 0 {
imgBounds.origin.y = imgBounds.origin.y - 1
}
let path = UIBezierPath(roundedRect: imgBounds, cornerRadius: cornerRadius)
value.setFill()
path.fill()
value.setStroke()
}
}
}
}
}
extension CTFrame {
var lines: [CTLine] {
let linesAO: [AnyObject] = CTFrameGetLines(self) as [AnyObject]
guard let lines = linesAO as? [CTLine] else {
return []
}
return lines
}
}
extension CTLine {
var ctruns: [CTRun] {
let linesAO: [AnyObject] = CTLineGetGlyphRuns(self) as [AnyObject]
guard let lines = linesAO as? [CTRun] else {
return []
}
return lines
}
}

PDFKit Ink Annotations

I've been working on a PDF editor / annotator using the iOS PDFKit framework. When I create annotations using the default options, they end up rendering strangely or not at all (which I've determined means they are rendering just outside the bounds of the annotation, since setting the annotation bounds to say {0,0,4000,4000} works). Now the obvious thing here is that I'm setting the bounds incorrectly, however when I subclass PDFAnnotation and override the draw(with:,in:) method they appear as expected.
Doesn't work
// paths made using pdf coordinate space
let paths: [UIBezierPath] = []
// find bounding box of all the paths
let bounds = paths.reduce(CGMutablePath(), { acc, nxt in
acc.addPath(nxt.cgPath)
return acc
}).boundingBoxOfPath.insetBy(dx: -50, dy: -50)
let border = PDFBorder()
border.lineWidth = 2.5
border.style = .solid
annotation = PDFAnnotation(
bounds: bounds,
forType: .ink,
withProperties: [
PDFAnnotationKey.inklist: paths,
PDFAnnotationKey.border: border,
PDFAnnotationKey.color: UIColor.red,
PDFAnnotationKey.interiorColor: UIColor.red,
])
// Also tried these things / other fiddling but same results
annotation.backgroundColor = UIColor.red
annotation.shouldDisplay = true
for path in paths {
annotation.add(path)
}
Works great, but doesn't conform to the pdf spec
let paths: [UIBezierPath] = []
let bounds = paths.reduce(CGMutablePath(), { acc, nxt in
acc.addPath(nxt.cgPath)
return acc
}).boundingBoxOfPath.insetBy(dx: -50, dy: -50)
annotation = InkAnnotation(bounds: bounds, paths: paths)
/// Overrides draw method to add paths manually
class InkAnnotation: PDFAnnotation {
var pathList: [UIBezierPath] = []
public init(bounds: CGRect, paths: [UIBezierPath]) {
self.pathList = paths
super.init(bounds: bounds, forType: .ink, withProperties: nil)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(with box: PDFDisplayBox, in context: CGContext) {
UIGraphicsPushContext(context)
context.saveGState()
context.setStrokeColor(UIColor.red.cgColor)
context.setLineWidth(2.5)
for path in pathList {
context.addPath(path.cgPath)
context.strokePath()
}
context.strokePath()
context.restoreGState()
UIGraphicsPopContext()
}
}
Any help is greatly appreciated!

How to capture a full page screenshot of WKWebview?

I can capture all content screenshot of UIWebView by adjust frame of UIScrollView of UIWebView. However, I can't capture all content screenshot of WKWebView by the same method.
The method I used for capture UIWebView as follow:
backup frame and superview of webview.scrollView
create a new container view as webview.scrollView's superview
adjust frame of new container view and webview.scrollView. frame is same to webview.scrollView.contentSize
draw by renderInContext or drawViewHierarchyInRect
However, this method will capture a white screenshot of WKWebview. It doesn't work!
I had print all level of WKWebView, then I found a UIView(WKContentView)'s size is same to contentView, you can found this view by this level:
WKWebView
WKScrollView
WKContentView(size is same to contentView)
I also had try to capture by WKContentView, then I found only visible view could be captured.
Anyway, Anyone could tell me how to capture a full page content screenshot of WKWebView?
Please refer to this answer
iOS 11.0 and above, Apple has provided following API to capture snapshot of WKWebView.
#available(iOS 11.0, *)
open func takeSnapshot(with snapshotConfiguration: WKSnapshotConfiguration?, completionHandler: #escaping (UIImage?, Error?) -> Swift.Void)
Swift 3, Xcode 8
func takeScreenshot() -> UIImage? {
let currentSize = webView.frame.size
let currentOffset = webView.scrollView.contentOffset
webView.frame.size = webView.scrollView.contentSize
webView.scrollView.setContentOffset(CGPoint.zero, animated: false)
let rect = CGRect(x: 0, y: 0, width: webView.bounds.size.width, height: webView.bounds.size.height)
UIGraphicsBeginImageContextWithOptions(rect.size, false, UIScreen.main.scale)
webView.drawHierarchy(in: rect, afterScreenUpdates: true)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
webView.frame.size = currentSize
webView.scrollView.setContentOffset(currentOffset, animated: false)
return image
}
Swift 3, Xcode 9:
I created an extension to achieve this. Hope this helps you.
extension WKWebView{
private func stageWebViewForScreenshot() {
let _scrollView = self.scrollView
let pageSize = _scrollView.contentSize;
let currentOffset = _scrollView.contentOffset
let horizontalLimit = CGFloat(ceil(pageSize.width/_scrollView.frame.size.width))
let verticalLimit = CGFloat(ceil(pageSize.height/_scrollView.frame.size.height))
for i in stride(from: 0, to: verticalLimit, by: 1.0) {
for j in stride(from: 0, to: horizontalLimit, by: 1.0) {
_scrollView.scrollRectToVisible(CGRect(x: _scrollView.frame.size.width * j, y: _scrollView.frame.size.height * i, width: _scrollView.frame.size.width, height: _scrollView.frame.size.height), animated: true)
RunLoop.main.run(until: Date.init(timeIntervalSinceNow: 1.0))
}
}
_scrollView.setContentOffset(currentOffset, animated: false)
}
func fullLengthScreenshot(_ completionBlock: ((UIImage) -> Void)?) {
// First stage the web view so that all resources are downloaded.
stageWebViewForScreenshot()
let _scrollView = self.scrollView
// Save the current bounds
let tmp = self.bounds
let tmpFrame = self.frame
let currentOffset = _scrollView.contentOffset
// Leave main thread alone for some time to let WKWebview render its contents / run its JS to load stuffs.
mainDispatchAfter(2.0) {
// Re evaluate the size of the webview
let pageSize = _scrollView.contentSize
UIGraphicsBeginImageContext(pageSize)
self.bounds = CGRect(x: self.bounds.origin.x, y: self.bounds.origin.y, width: pageSize.width, height: pageSize.height)
self.frame = CGRect(x: self.frame.origin.x, y: self.frame.origin.y, width: pageSize.width, height: pageSize.height)
// Wait few seconds until the resources are loaded
RunLoop.main.run(until: Date.init(timeIntervalSinceNow: 0.5))
self.layer.render(in: UIGraphicsGetCurrentContext()!)
let image: UIImage = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext()
// reset Frame of view to origin
self.bounds = tmp
self.frame = tmpFrame
_scrollView.setContentOffset(currentOffset, animated: false)
completionBlock?(image)
}
}
}
The key here is to let capture after webkit be allowed time to render after it is given a new frame. I tried didFinishLoading calback and WKWebView's wkWebView.takeSnapshot, but did not work.
So I introduced an a delay for the screenshot:
func createImage(webView: WKWebView, completion: #escaping (UIImage?) -> ()) {
// save the original size to restore
let originalFrame = webView.frame
let originalConstraints = webView.constraints
let originalScrollViewOffset = webView.scrollView.contentOffset
let newSize = webView.scrollView.contentSize
// remove any constraints for the web view, and set the size
// to be size of the content size (will be restored later)
webView.removeConstraints(originalConstraints)
webView.translatesAutoresizingMaskIntoConstraints = true
webView.frame = CGRect(origin: .zero, size: newSize)
webView.scrollView.contentOffset = .zero
// wait for a while for the webview to render in the newly set frame
DispatchQueue.main.asyncAfter(deadline: .now() + 4.0) {
defer {
UIGraphicsEndImageContext()
}
UIGraphicsBeginImageContextWithOptions(newSize, false, 0)
if let context = UIGraphicsGetCurrentContext() {
// render the scroll view's layer
webView.scrollView.layer.render(in: context)
// restore the original state
webView.frame = originalFrame
webView.translatesAutoresizingMaskIntoConstraints = false
webView.addConstraints(originalConstraints)
webView.scrollView.contentOffset = originalScrollViewOffset
if let image = UIGraphicsGetImageFromCurrentImageContext() {
completion(image)
} else {
completion(nil)
}
}
}
}
Result:
- xcode 10 & swift 4.2 -
Use this;
// this is the button which takes the screenshot
#IBAction func snapShot(_ sender: Any) {
captureScreenshot()
}
// this is the function which is the handle screenshot functionality.
func captureScreenshot(){
let layer = UIApplication.shared.keyWindow!.layer
let scale = UIScreen.main.scale
// Creates UIImage of same size as view
UIGraphicsBeginImageContextWithOptions(layer.frame.size, false, scale);
layer.render(in: UIGraphicsGetCurrentContext()!)
let screenshot = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
// THIS IS TO SAVE SCREENSHOT TO PHOTOS
UIImageWriteToSavedPhotosAlbum(screenshot!, nil, nil, nil)
}
and you must add these keys in your info.plist;
key value = "Privacy - Photo Library Additions Usage Description" ,
key value = Privacy - Photo Library Usage Description
Here is an iOS 11+ example for snapshotting the WKWebView without the need of any delays to render the content.
The key is to make sure that you dont receive a blank snapshot. Initially we had issues with the output image being blank and having to introduce a delay to have the web content in the output image. I see the solution with the delay in many posts and I would recommend to not use it because it is an unnecessarily unreliable and unperformant solution. The important solution for us was to set the rect of WKSnapshotConfiguration and to use the JavaScript functions to wait for the rendering and also receiving the correct width and height.
Setting up the WKWebView:
// Important: You can set a width here which will also be reflected in the width of the snapshot later
let webView = WKWebView(frame: .zero)
webView.configuration.dataDetectorTypes = []
webView.navigationDelegate = self
webView.scrollView.contentInsetAdjustmentBehavior = .never
webView.loadHTMLString(htmlString, baseURL: Bundle.main.resourceURL)
Capturing the snapshot:
func webView(
_ webView: WKWebView,
didFinish navigation: WKNavigation!
) {
// Wait for the page to be rendered completely and get the final size from javascript
webView.evaluateJavaScript("document.readyState", completionHandler: { [weak self] (readyState, readyStateError) in
webView.evaluateJavaScript("document.documentElement.scrollWidth", completionHandler: {(contentWidth, widthError) in
webView.evaluateJavaScript("document.documentElement.scrollHeight", completionHandler: { (contentHeight, heightError) in
guard readyState != nil,
let contentHeight = contentHeight as? CGFloat,
let contentWidth = contentWidth as? CGFloat else {
[Potential error handling...]
return
}
let rect = CGRect(
x: 0,
y: 0,
width: contentWidth,
height: contentHeight
)
let configuration = WKSnapshotConfiguration()
configuration.rect = rect
if #available(iOS 13.0, *) {
configuration.afterScreenUpdates = true
}
webView.takeSnapshot(with: configuration) { (snapshotImage, error) in
guard let snapshotImage = snapshotImage else {
[Potential error handling...]
}
[Do something with the image]
}
})
})
})
}
UPDATE: Not sure why Jason is sticking this answer all over the net. IT DOES NOT solve the problem of HOW to Screenshot the Full WKWebView content... including that off the screen.
Try this:
func snapshot() -> UIImage {
UIGraphicsBeginImageContextWithOptions(self.webView.bounds.size, true, 0);
self.webView.drawViewHierarchyInRect(self.webView.bounds, afterScreenUpdates: true);
let snapshotImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
UIImageWriteToSavedPhotosAlbum(snapshotImage, nil, nil, nil)
return snapshotImage
}
The image will automatically save into the iOS Camera Roll (by UIImageWriteToSavedPhotosAlbum()).

NSAttributedString background color and rounded corners

I have a question regarding rounded corners and text background color for a custom UIView.
Basically, I need to achieve an effect like this (image attached - notice the rounded corners on one side) in a custom UIView:
I'm thinking the approach to use is:
Use Core Text to get glyph runs.
Check highlight range.
If the current run is within the highlight range, draw a background rectangle with rounded corners and desired fill color before drawing the glyph run.
Draw the glyph run.
However, I'm not sure whether this is the only solution (or for that matter, whether this is the most efficient solution).
Using a UIWebView is not an option, so I have to do it in a custom UIView.
My question being, is this the best approach to use, and am I on the right track? Or am I missing out something important or going about it the wrong way?
TL;DR; Create a custom-view, which renders same old NSAttributedString, but with rounded-corners.
Unlike Android's SpannableString, iOS does not support "custom-render for custom-string-attributes", at least not without an entire custom-view (at time of writing, 2022).
I managed to achieve the above effect, so thought I'd post an answer for the same.
If anyone has any suggestions about making this more effective, please feel free to contribute. I'll be sure to mark your answer as the correct one. :)
For doing this, you'll need to add a "custom attribute" to NSAttributedString.
Basically, what that means is that you can add any key-value pair, as long as it is something that you can add to an NSDictionary instance. If the system does not recognize that attribute, it does nothing. It is up to you, as the developer, to provide a custom implementation and behavior for that attribute.
For the purposes of this answer, let us assume I've added a custom attribute called: #"MyRoundedBackgroundColor" with a value of [UIColor greenColor].
For the steps that follow, you'll need to have a basic understanding of how CoreText gets stuff done. Check out Apple's Core Text Programming Guide for understanding what's a frame/line/glyph run/glyph, etc.
So, here are the steps:
Create a custom UIView subclass.
Have a property for accepting an NSAttributedString.
Create a CTFramesetter using that NSAttributedString instance.
Override the drawRect: method
Create a CTFrame instance from the CTFramesetter.
You will need to give a CGPathRef to create the CTFrame. Make that CGPath to be the same as the frame in which you wish to draw the text.
Get the current graphics context and flip the text coordinate system.
Using CTFrameGetLines(...), get all the lines in the CTFrame you just created.
Using CTFrameGetLineOrigins(...), get all the line origins for the CTFrame.
Start a for loop - for each line in the array of CTLine...
Set the text position to the start of the CTLine using CGContextSetTextPosition(...).
Using CTLineGetGlyphRuns(...) get all the Glyph Runs (CTRunRef) from the CTLine.
Start another for loop - for each glyphRun in the array of CTRun...
Get the range of the run using CTRunGetStringRange(...).
Get typographic bounds using CTRunGetTypographicBounds(...).
Get the x offset for the run using CTLineGetOffsetForStringIndex(...).
Calculate the bounding rect (let's call it runBounds) using the values returned from the aforementioned functions.
Remember - CTRunGetTypographicBounds(...) requires pointers to variables to store the "ascent" and "descent" of the text. You need to add those to get the run height.
Get the attributes for the run using CTRunGetAttributes(...).
Check if the attribute dictionary contains your attribute.
If your attribute exists, calculate the bounds of the rectangle that needs to be painted.
Core text has the line origins at the baseline. We need to draw from the lowermost point of the text to the topmost point. Thus, we need to adjust for descent.
So, subtract the descent from the bounding rect that we calculated in step 16 (runBounds).
Now that we have the runBounds, we know what area we want to paint - now we can use any of the CoreGraphis/UIBezierPath methods to draw and fill a rect with specific rounded corners.
UIBezierPath has a convenience class method called bezierPathWithRoundedRect:byRoundingCorners:cornerRadii: that let's you round specific corners. You specify the corners using bit masks in the 2nd parameter.
Now that you've filled the rect, simply draw the glyph run using CTRunDraw(...).
Celebrate victory for having created your custom attribute - drink a beer or something! :D
Regarding detecting that the attribute range extends over multiple runs, you can get the entire effective range of your custom attribute when the 1st run encounters the attribute. If you find that the length of the maximum effective range of your attribute is greater than the length of your run, you need to paint sharp corners on the right side (for a left to right script). More math will let you detect the highlight corner style for the next line as well. :)
Attached is a screenshot of the effect. The box on the top is a standard UITextView, for which I've set the attributedText. The box on the bottom is the one that has been implemented using the above steps. The same attributed string has been set for both the textViews.
Again, if there is a better approach than the one that I've used, please do let me know! :D
Hope this helps the community. :)
Cheers!
Just customize NSLayoutManager and override drawUnderline(forGlyphRange:underlineType:baselineOffset:lineFragmentRect:lineFragmentGlyphRange:containerOrigin:) Apple API Document
In this method, you can draw underline by yourself, Swift code,
override func drawUnderline(forGlyphRange glyphRange: NSRange,
underlineType underlineVal: NSUnderlineStyle,
baselineOffset: CGFloat,
lineFragmentRect lineRect: CGRect,
lineFragmentGlyphRange lineGlyphRange: NSRange,
containerOrigin: CGPoint
) {
let firstPosition = location(forGlyphAt: glyphRange.location).x
let lastPosition: CGFloat
if NSMaxRange(glyphRange) < NSMaxRange(lineGlyphRange) {
lastPosition = location(forGlyphAt: NSMaxRange(glyphRange)).x
} else {
lastPosition = lineFragmentUsedRect(
forGlyphAt: NSMaxRange(glyphRange) - 1,
effectiveRange: nil).size.width
}
var lineRect = lineRect
let height = lineRect.size.height * 3.5 / 4.0 // replace your under line height
lineRect.origin.x += firstPosition
lineRect.size.width = lastPosition - firstPosition
lineRect.size.height = height
lineRect.origin.x += containerOrigin.x
lineRect.origin.y += containerOrigin.y
lineRect = lineRect.integral.insetBy(dx: 0.5, dy: 0.5)
let path = UIBezierPath(rect: lineRect)
// let path = UIBezierPath(roundedRect: lineRect, cornerRadius: 3)
// set your cornerRadius
path.fill()
}
Then construct your NSAttributedString and add attributes .underlineStyle and .underlineColor.
addAttributes(
[
.foregroundColor: UIColor.white,
.underlineStyle: NSUnderlineStyle.single.rawValue,
.underlineColor: UIColor(red: 51 / 255.0, green: 154 / 255.0, blue: 1.0, alpha: 1.0)
],
range: range
)
That's it!
I did it by checking frames of text fragments. In my project I needed to highlight hashtags while a user is typing text.
class HashtagTextView: UITextView {
let hashtagRegex = "#[-_0-9A-Za-z]+"
private var cachedFrames: [CGRect] = []
private var backgrounds: [UIView] = []
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
configureView()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configureView()
}
override func layoutSubviews() {
super.layoutSubviews()
// Redraw highlighted parts if frame is changed
textUpdated()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
#objc private func textUpdated() {
// You can provide whatever ranges needed to be highlighted
let ranges = resolveHighlightedRanges()
let frames = ranges.compactMap { frame(ofRange: $0) }.reduce([], +)
if cachedFrames != frames {
cachedFrames = frames
backgrounds.forEach { $0.removeFromSuperview() }
backgrounds = cachedFrames.map { frame in
let background = UIView()
background.backgroundColor = UIColor.hashtagBackground
background.frame = frame
background.layer.cornerRadius = 5
insertSubview(background, at: 0)
return background
}
}
}
/// General setup
private func configureView() {
NotificationCenter.default.addObserver(self, selector: #selector(textUpdated), name: UITextView.textDidChangeNotification, object: self)
}
/// Looks for locations of the string to be highlighted.
/// The current case - ranges of hashtags.
private func resolveHighlightedRanges() -> [NSRange] {
guard text != nil, let regex = try? NSRegularExpression(pattern: hashtagRegex, options: []) else { return [] }
let matches = regex.matches(in: text, options: [], range: NSRange(text.startIndex..<text.endIndex, in: text))
let ranges = matches.map { $0.range }
return ranges
}
}
There is also a helper extension to determine frames of ranges:
extension UITextView {
func convertRange(_ range: NSRange) -> UITextRange? {
let beginning = beginningOfDocument
if let start = position(from: beginning, offset: range.location), let end = position(from: start, offset: range.length) {
let resultRange = textRange(from: start, to: end)
return resultRange
} else {
return nil
}
}
func frame(ofRange range: NSRange) -> [CGRect]? {
if let textRange = convertRange(range) {
let rects = selectionRects(for: textRange)
return rects.map { $0.rect }
} else {
return nil
}
}
}
Result text view:
I wrote the below code following the #codeBearer answer.
import UIKit
class CustomAttributedTextView: UITextView {
override func layoutSubviews() {
super.layoutSubviews()
}
func clearForReuse() {
setNeedsDisplay()
}
var lineCountUpdate: ((Bool) -> Void)?
override func draw(_ rect: CGRect) {
super.draw(rect)
UIColor.clear.setFill()
UIColor.clear.setFill()
guard let context = UIGraphicsGetCurrentContext() else { return }
context.textMatrix = .identity
context.translateBy(x: 0, y: bounds.size.height)
context.scaleBy(x: 1.0, y: -1.0)
let path = CGMutablePath()
let size = sizeThatFits(CGSize(width: self.frame.width, height: .greatestFiniteMagnitude))
path.addRect(CGRect(x: 0, y: 0, width: size.width, height: size.height), transform: .identity)
let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString)
let frame: CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attributedText.length), path, nil)
let lines: [CTLine] = frame.lines
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
for lineIndex in 0..<lines.count {
let line = lines[lineIndex]
let runs: [CTRun] = line.ctruns
var tagCountInOneLine = 0
for run in runs {
var cornerRadius: CGFloat = 3
let attributes: NSDictionary = CTRunGetAttributes(run)
var imgBounds: CGRect = .zero
if let value: UIColor = attributes.value(forKey: NSAttributedString.Key.customBackgroundColor.rawValue) as? UIColor {
var ascent: CGFloat = 0
imgBounds.size.width = CGFloat(CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, nil, nil) + 4)
imgBounds.size.height = ascent + 6
let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil)
imgBounds.origin.x = origins[lineIndex].x + xOffset + 3
imgBounds.origin.y = origins[lineIndex].y - 13
if lineIndex != 0 {
imgBounds.origin.y = imgBounds.origin.y - 1
}
let path = UIBezierPath(roundedRect: imgBounds, cornerRadius: cornerRadius)
value.setFill()
path.fill()
value.setStroke()
}
}
}
}
}
extension CTFrame {
var lines: [CTLine] {
let linesAO: [AnyObject] = CTFrameGetLines(self) as [AnyObject]
guard let lines = linesAO as? [CTLine] else {
return []
}
return lines
}
}
extension CTLine {
var ctruns: [CTRun] {
let linesAO: [AnyObject] = CTLineGetGlyphRuns(self) as [AnyObject]
guard let lines = linesAO as? [CTRun] else {
return []
}
return lines
}
}

Resources