Your How-To Guide for Implementing Image Cropping and Rotating in iOS

Mobile@Exxeta
14 min readMar 21, 2024
Film rolls
Photo by freestocks.org from Pexels

As iOS developers, we often face the challenge of having to implement features for which there is no native component from Apple. A classic example is image editing — a basic functionality required in many apps, yet, surprisingly, not natively supported by iOS. This blog post aims to fill that gap. We will share a tailor-made crop component with rotation capability that can be easily integrated into any iOS app to provide an efficient and user-friendly image cropping function, simplifying the development of apps with image editing features.

Implementing the User Interface

So let us start with the basic user interface first.

User interface elements

import UIKit

/// Shows the image and includes crop and rotation functionality
class ImageCropperView: UIView {

// MARK: - Private Properties
/// The image we are editing
private var editableImage: UIImage

/// UIImageView displays our image
private let imageView = UIImageView()

/// Top left button
private let topLeftButton = UIButton()

/// Top right button
private let topRightButton = UIButton()

/// Bottom left button
private let bottomLeftButton = UIButton()

/// Bottom right button
private let bottomRightButton = UIButton()

/// Crop button
private let cropButton = UIButton(configuration: .filled())

/// Rotate button
private let rotateButton = UIButton(configuration: .filled())

/// Button size is twice as big as the icon we use for our button to have a bigger pan space
private let buttonSize: CGFloat = 64

/// Current image frame
private var imageFrame: CGRect?

}

Our UI includes a UIImageView to display the image, four UIButtons to specify the crop area, and two UIButtons to trigger the crop and rotate actions.

Setup functions

The next step is to define our initializer with some setup functions.

// MARK: - Initialization
/// Initializer
init(editableImage: UIImage) {
self.editableImage = editableImage
super.init(frame: .zero)

setupViews()
setupConstraints()
}

/// Required initializer
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

// MARK: - Private Functions
/// Setup of the views
private func setupViews() { }

/// Setup of the constraining
private func setupConstraints() { }

These are our setup functions and our initializer. The initializer takes the image we want to edit and initializes our editableImage property.

Setup views

Let’s start by defining our setupViews() function.

/// Setup of the views
private func setupViews() {
addSubview(imageView)

imageView.image = editableImage
imageView.contentMode = .scaleAspectFit
imageView.backgroundColor = UIColor.white

addSubview(topLeftButton)
addSubview(topRightButton)
addSubview(bottomLeftButton)
addSubview(bottomRightButton)

var configuration = UIButton.Configuration.plain()
configuration.cornerStyle = .capsule

[topLeftButton, topRightButton, bottomLeftButton, bottomRightButton].forEach { button in
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(buttonPanGestureAction))
button.addGestureRecognizer(panGestureRecognizer)
button.configuration = configuration
}

/// Here we use different images for each button to visualize the button origin.
/// If you want to use one image for each button you can do in the button config above. (Icon size 32x32 due to buttonSize property)
topLeftButton.configuration?.image = UIImage(named: "circle-arrow-up-left")
topRightButton.configuration?.image = UIImage(named: "circle-arrow-up-right")
bottomLeftButton.configuration?.image = UIImage(named: "circle-arrow-down-left")
bottomRightButton.configuration?.image = UIImage(named: "circle-arrow-down-right")

addSubview(cropButton)
addSubview(rotateButton)

cropButton.setTitle("Crop", for: .normal)
cropButton.configuration?.baseBackgroundColor = UIColor(named: "brand")
cropButton.addTarget(self, action: #selector(cropAction), for: .touchUpInside)

rotateButton.setTitle("Rotate", for: .normal)
rotateButton.configuration?.baseBackgroundColor = UIColor(named: "brand")
rotateButton.addTarget(self, action: #selector(rotateAction), for: .touchUpInside)
}

/// Pan gesture handling for the 4 buttons that form the crop area
@objc
private func buttonPanGestureAction(_ gesture: UIPanGestureRecognizer) { }

/// Crop image action
@objc
private func cropAction() { }

/// Rotates the original image and updates all layers
@objc
private func rotateAction() { }

In the setupViews() function, we perform simple setup operations, such as adding the UIImageView and UIButtons as a subview, styling them and adding the pan gesture. As you can see, we have also defined the buttonPanGestureAction(_ gesture:), cropAction(), and rotateAction() functions. We will return to these later.

Setup constraints

Pretty straightforward so far. Now we move on to the setupConstraints() function.

/// Setup of the constraining
private func setupConstraints() {
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.topAnchor.constraint(equalTo: self.safeAreaLayoutGuide.topAnchor).isActive = true
imageView.leftAnchor.constraint(equalTo: self.leftAnchor).isActive = true
imageView.rightAnchor.constraint(equalTo: self.rightAnchor).isActive = true
imageView.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true

[topLeftButton, topRightButton, bottomLeftButton, bottomRightButton].forEach { button in
button.translatesAutoresizingMaskIntoConstraints = false
button.widthAnchor.constraint(equalToConstant: buttonSize).isActive = true
button.heightAnchor.constraint(equalToConstant: buttonSize).isActive = true
}

cropButton.translatesAutoresizingMaskIntoConstraints = false
cropButton.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 40).isActive = true
cropButton.leftAnchor.constraint(equalTo: self.leftAnchor, constant: 16).isActive = true
cropButton.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -16).isActive = true
cropButton.heightAnchor.constraint(equalToConstant: 44).isActive = true

rotateButton.translatesAutoresizingMaskIntoConstraints = false
rotateButton.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 40).isActive = true
rotateButton.leftAnchor.constraint(equalTo: cropButton.rightAnchor, constant: 16).isActive = true
rotateButton.rightAnchor.constraint(equalTo: self.rightAnchor, constant: -16).isActive = true
rotateButton.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -16).isActive = true
rotateButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
rotateButton.widthAnchor.constraint(equalTo: cropButton.widthAnchor).isActive = true
}

Here, we simply add some constraints to our imageView and our buttons. Let’s move on to calculating the image frame.

Essential Functions

Update image frame

/// Updates the 'imageFrame' property
private func updateImageFrame() {
let originalImageSize = editableImage.size
let heightAspectRatio = originalImageSize.height / originalImageSize.width
let imageHeight = min(frame.width * heightAspectRatio, imageView.frame.height)
let imageWidth = imageHeight / heightAspectRatio
let imageSize = CGSize(width: imageWidth, height: imageHeight)
let verticalInset = max(0, (imageView.frame.height - imageSize.height) / 2)
let horizontalInset = (imageView.frame.width - imageSize.width) / 2

self.imageFrame = CGRect(origin: CGPoint(x: horizontalInset, y: verticalInset), size: imageSize)
}

This is a very important function. At the beginning and after every change we make to our image, we need to recalculate the image frame. The image borders change after cropping and rotating the image. So, for all the calculations we are going to do, this function is essential to do the calculations with the latest image frame.

First, we get the image size. Then we calculate the aspect ratio of the image to get its height and width in relation to the boundaries of the ImageCropperView.

Then we calculate the possible vertical and horizontal insets that can occur. We need these insets to finally define the frame of the image. We can use the vertical inset as an example in order to better understand the calculation. To calculate the total vertical inset of the image inside the imageView, we can simply subtract the image height from the imageView height. The image is centered within the imageView, so the vertical inset can be divided into two parts: the top inset, the empty space above the image, and the bottom inset, the empty space below the image. So, what we need to do now to get the y value for our imageFrame is to divide it by two, to get half of the total vertical inset.

Update image frame illustration
Update image frame illustration

Finally, we set the insets as the origin and the calculated height and width as the size to define the current image frame.

Set up buttons

Next, we need to set the initial positions for our buttons.

// MARK: - Lifecycle
override func layoutSubviews() {
super.layoutSubviews()

setupDefaultCropRectangle()
}

/// Setup of the default crop rectangle
private func setupDefaultCropRectangle() {
updateImageFrame()

guard let imageFrame else {
Log.warning("Could not unwrap image frame", error: LogError.warning)
return
}

let inset = frame.width / 10
let topLeft = CGPoint(
x: imageFrame.minX + inset,
y: imageFrame.minY + inset
)
let topRight = CGPoint(
x: imageFrame.maxX - inset,
y: imageFrame.minY + inset
)
let bottomLeft = CGPoint(
x: imageFrame.minX + inset,
y: imageFrame.maxY - inset
)
let bottomRight = CGPoint(
x: imageFrame.maxX - inset,
y: imageFrame.maxY - inset
)

topLeftButton.center = topLeft
topRightButton.center = topRight
bottomLeftButton.center = bottomLeft
bottomRightButton.center = bottomRight
}

This is where we set the origin of the buttons. We decided to initially place the buttons with a 10% inset depending on the width of the frame, to prevent the buttons from being on the edges. If you only want to use this crop component for documents, you can detect the four corners of the document using the Vision framework with the VNDocumentSegmentationRequest. If you want to know more about this topic, let us know it in the comments.

Next, we need to add the logic inside the buttonPanGestureAction(_ gesture:) function.

/// Pan gesture handling for the 4 buttons that form the crop area
@objc
private func buttonPanGestureAction(_ gesture: UIPanGestureRecognizer) {
/// Unwrap the button that triggered the pan gesture
guard let button = gesture.view else {
Log.warning("buttonPanGestureAction received no view for positioning update", error: LogError.warning)
return
}

/// Custom duration of the animation
let animationDuration: CGFloat = 0.1
/// Custom maximum scale of the animation
let buttonMaxScale: CGFloat = 2

/// Handle began and ended states for button scale animation
switch gesture.state {
case .began:
UIView.animate(withDuration: animationDuration, delay: 0, options: .curveLinear) {
button.transform = CGAffineTransform(scaleX: buttonMaxScale, y: buttonMaxScale)
}
case .ended:
UIView.animate(withDuration: animationDuration, delay: 0, options: .curveLinear) {
button.transform = .identity
}
default:
Log.debug("UIPanGestureRecognizer state \(gesture.state) not handled in ReceiptEditCropImageView")
}

guard let imageFrame else {
Log.warning("Could not unwrap current image frame", error: LogError.warning)
return
}

/// Here we calculate our min, max x and y values
let minXSafeArea: CGFloat = imageFrame.origin.x
let maxXSafeArea: CGFloat = imageFrame.origin.x + imageFrame.width
let minYSafeArea: CGFloat = imageFrame.origin.y
let maxYSafeArea: CGFloat = imageFrame.height + minYSafeArea

/// Maximum x for the left buttons gets calculated
let topLeftButtonMaxX = topRightButton.center.x
let bottomLeftButtonMaxX = bottomRightButton.center.x
let leftButtonsMaxX = min(topLeftButtonMaxX, bottomLeftButtonMaxX)

/// Minimum x for the right buttons gets calculated
let topRightButtonMinX = topLeftButton.center.x
let bottomRightButtonMinX = bottomLeftButton.center.x
let rightButtonsMinX = max(topRightButtonMinX, bottomRightButtonMinX)

/// Maximum y for the top buttons gets calculated
let topRightButtonMaxY = bottomRightButton.center.y
let topLeftButtonMaxY = bottomLeftButton.center.y
let topButtonsMaxY = min(topRightButtonMaxY, topLeftButtonMaxY)

/// Minimum y for the bottom buttons gets calculated
let bottomRightButtonMinY = topRightButton.center.y
let bottomLeftButtonMinY = topLeftButton.center.y
let bottomButtonsMinY = max(bottomRightButtonMinY, bottomLeftButtonMinY)

/// Current point of the gesture in relation to the ImageCropperView
let point = gesture.translation(in: self)

/// Here we work with the previously calculated max and min x,y values
/// to ensure that the buttons can not be panned outside of the image
/// frame or the buttons do not overlap and invalidate our final frame.
let xPosition: CGFloat
let yPosition: CGFloat
if button === topLeftButton {
xPosition = max(minXSafeArea, min(button.center.x + point.x, leftButtonsMaxX))
yPosition = max(minYSafeArea, min(button.center.y + point.y, topButtonsMaxY))
} else if button === topRightButton {
xPosition = min(maxXSafeArea, max(button.center.x + point.x, rightButtonsMinX))
yPosition = max(minYSafeArea, min(button.center.y + point.y, topButtonsMaxY))
} else if button === bottomLeftButton {
xPosition = max(minXSafeArea, min(button.center.x + point.x, leftButtonsMaxX))
yPosition = min(maxYSafeArea, max(button.center.y + point.y, bottomButtonsMinY))
} else if button === bottomRightButton {
xPosition = min(maxXSafeArea, max(button.center.x + point.x, rightButtonsMinX))
yPosition = min(maxYSafeArea, max(button.center.y + point.y, bottomButtonsMinY))
} else { return }

/// Set the new position of the button
button.center = CGPoint(x: xPosition, y: yPosition)
gesture.setTranslation(CGPoint.zero, in: self)
}

In the buttonPanGestureAction(_ gesture:) function we define how each of the four buttons should handle the pan gesture. We need to set boundaries for all four buttons and relate them to each other to ensure that the buttons do not overlap and invalidate the rect and that the buttons do not move out of the image frame. We also decided to scale a button up while it is pressed so that it is still visible under the finger.

When you run your application now, your UI should look like this:

UI after setting up the first UI elements
UI after setting up the first UI elements

It’s OK, but there’s still something missing. It would look much better with an overlay that cuts out the area of interest and connects the four crop buttons.

In order to do this, we need three CAShapeLayers.

Overlay

/// Layer for showing the dashed lines of the crop area
private let rectangleLayer = CAShapeLayer()

/// Layer for showing the darkened background of the crop area
private let backgroundLayer = CAShapeLayer()

/// Mask layer to cut out the region of interest
private let maskLayer = CAShapeLayer()

/// Setup of the views
private func setupViews() {
...

layer.addSublayer(backgroundLayer)
layer.addSublayer(rectangleLayer)

maskLayer.fillRule = .evenOdd

backgroundLayer.mask = maskLayer
backgroundLayer.fillColor = UIColor.black.cgColor
backgroundLayer.opacity = Float(0.5)

rectangleLayer.strokeColor = UIColor.white.cgColor
rectangleLayer.fillColor = UIColor.clear.cgColor
rectangleLayer.lineWidth = 2
rectangleLayer.lineJoin = .round
let dashPatternFour = NSNumber(floatLiteral: 4)
rectangleLayer.lineDashPattern = [dashPatternFour, dashPatternFour]
}

/// Draws the crop area
private func drawRectangle() {
guard let imageFrame else {
Log.warning("Could not unwrap current image frame", error: LogError.warning)
return
}

/// Rectangle layer path with dashed lines
let rectangle = UIBezierPath.init()

rectangle.move(to: topLeftButton.center)

rectangle.addLine(to: topLeftButton.center)
rectangle.addLine(to: topRightButton.center)
rectangle.addLine(to: bottomRightButton.center)
rectangle.addLine(to: bottomLeftButton.center)
rectangle.addLine(to: topLeftButton.center)

rectangle.close()

rectangleLayer.path = rectangle.cgPath

/// Mask for centered rectangle cut
let mask = UIBezierPath.init(rect: imageFrame)

mask.move(to: topLeftButton.center)

mask.addLine(to: topLeftButton.center)
mask.addLine(to: topRightButton.center)
mask.addLine(to: bottomRightButton.center)
mask.addLine(to: bottomLeftButton.center)
mask.addLine(to: topLeftButton.center)

mask.close()

maskLayer.path = mask.cgPath

/// Background layer
let path = UIBezierPath(rect: imageFrame)
backgroundLayer.path = path.cgPath
}

Next we need to use this function in the right place.

/// Setup of the initial crop rectangle
private func setupDefaultCropRectangle() {
...

drawRectangle()
}

/// Pan gesture handling for the 4 buttons that form the crop area
@objc
private func buttonPanGestureAction(_ gesture: UIPanGestureRecognizer) {
...

drawRectangle()
}

So now we draw our rectangle overlay as soon as setupDefaultCropRectangle() is called in the layoutSubviews() function and when we pan the buttons. It is important to make the initial call in the layoutSubviews() function, because when it is called, the frames of the views are set and we can start the calculation. Our UI should now look like this:

UI after adding the overlay
UI after adding the overlay

Much better. Now it is time for the rotate action. As we have our overlay with the four buttons, we need to recalculate their positions after we have triggered the rotation of the image. To do this, we first need an extension for rotating a UIImage.

Rotation

extension UIImage {
/// Returns a new image that is rotated by the defined radians value
func rotate(radians: Float) -> UIImage? {
/// The image's new size after rotation is calculated. This is crucial because rotating an image can change its width and height
var newSize = CGRect(origin: CGPoint.zero, size: self.size).applying(CGAffineTransform(rotationAngle: CGFloat(radians))).size

/// Get the next largest integer less than or equal to the size for width and height
newSize.width = floor(newSize.width)
newSize.height = floor(newSize.height)

/// A new bitmap-based graphics context is created with the new image size, allowing for high-quality image manipulation
UIGraphicsBeginImageContextWithOptions(newSize, false, self.scale)

/// The context's origin is translated to the new image's center, ensuring the rotation occurs around the center point
guard let context = UIGraphicsGetCurrentContext() else { return nil }
context.translateBy(x: newSize.width/2, y: newSize.height/2)

/// The context is rotated by the specified number of radians, setting the stage for the new image rendering
context.rotate(by: CGFloat(radians))

/// The original image is drawn onto the rotated context, resulting in a rotated image
self.draw(in: CGRect(x: -self.size.width/2, y: -self.size.height/2, width: self.size.width, height: self.size.height))

/// The rotated image is extracted from the context and prepared for return
let newImage = UIGraphicsGetImageFromCurrentImageContext()

/// The graphics context is closed, ensuring that all resources are properly released
UIGraphicsEndImageContext()

return newImage
}
}

With this UIImage extension function, we can rotate an UIImage by a given degree. Next, we need a function to calculate the new position of the buttons after the rotation.

extension UIView {

/// Rotates given point with mathematical formula by the center of the view and
/// regulates view size changes by the resize percentage
func rotatePointAroundCenter(
origin: CGPoint,
target: CGPoint,
resizePercentage: CGFloat
) -> CGPoint {
let destx = target.x - origin.x
let desty = target.y - origin.y
let radius = sqrt(destx * destx + desty * desty)
let azimuth = atan2(desty, destx)
let degree: CGFloat = 90
let newAzimuth = azimuth + degree * .pi / 180
let xPos = origin.x + radius * cos(newAzimuth) * resizePercentage /// x percentage in case of area size reduction or growth
let yPos = origin.y + radius * sin(newAzimuth) * resizePercentage /// y percentage in case of area size reduction or growth
return CGPoint(x: xPos, y: yPos)
}
}

This function calculates the new position for each button. The target is the center of the button, which we want to rotate around the center of the UIImageView, which is the origin. The resize percentage indicates whether the image will shrink or grow in size due to the rotation. It returns the percentage of the resize process for further calculations.

Now we are ready to implement our rotate action.

/// Rotates the original image and updates all layers
@objc
private func rotateAction() {
/// Rotate original image to the right
guard
let previousImageSize = imageFrame?.size,
let rotatedImage = editableImage.rotate(radians: .pi / 2) else {
Log.warning(
"Could not create the rotated image ",
error: LogError.warning
)
return
}

/// We have to nil out the image before setting to get desired visual result
imageView.image = nil
imageView.image = rotatedImage

editableImage = rotatedImage

/// After the rotated image is set, we are updating the original image bounds
updateImageFrame()
guard let newImageSize = imageFrame?.size else {
return
}

/// Here the percentage of resize is calculated
let resizePercentage = newImageSize.width / previousImageSize.height

/// The following handling is needed to keep all buttons at their intented position.
/// Meaning top, left, right, bottom. So their drag constraints align with their positions.
let topRightButtonPoint = rotatePointAroundCenter(
origin: imageView.center,
target: topLeftButton.center,
resizePercentage: resizePercentage
)

let bottomRightButtonPoint = rotatePointAroundCenter(
origin: imageView.center,
target: topRightButton.center,
resizePercentage: resizePercentage
)

let topLeftButtonPoint = rotatePointAroundCenter(
origin: imageView.center,
target: bottomLeftButton.center,
resizePercentage: resizePercentage
)

let bottomLeftButtonPoint = rotatePointAroundCenter(
origin: imageView.center,
target: bottomRightButton.center,
resizePercentage: resizePercentage
)

/// Here we are assigning the correct point for all buttons
topLeftButton.center = topLeftButtonPoint
topRightButton.center = topRightButtonPoint
bottomLeftButton.center = bottomLeftButtonPoint
bottomRightButton.center = bottomRightButtonPoint

/// After the points are correctly set, we are drawing the rectangle
drawRectangle()
}

As soon as an image gets rotated successfully, we update the image of the UIImageView and the editableImage property. To recalculate the image frame after we’ve rotated the image, we call the updateImageFrame() function. Then we calculate the new button positions with the rotatePointAroundCenter(origin:, target:, resizePercentage:)function and after assigning the new positions to the buttons, we call drawRectangle() to redraw the overlay.

When you launch the application, it should look like this:

UI after adding rotation functionality

So the last thing that we are missing is quite obvious ^^ the crop action.

Crop

/// Crop image action
@objc
private func cropAction() {
guard let imageFrame else {
Log.warning("Could not unwrap otiginal image bounds or editableOriginalImage", error: LogError.warning)
return
}

/// imageFrame x and y equal the half of the total vertical (y) and horizontal (x) inset.
let verticalInset = imageFrame.origin.y
let horizontalInset = imageFrame.origin.x

/// Subtract the insets from the calculated rect so they do not get recognized in the cropping process
let minX = min(topLeftButton.center.x, bottomLeftButton.center.x) - horizontalInset
let maxX = max(topRightButton.center.x, bottomRightButton.center.x) - horizontalInset
let minY = min(topLeftButton.center.y, topRightButton.center.y) - verticalInset
let maxY = max(bottomLeftButton.center.y, bottomRightButton.center.y) - verticalInset
let width = maxX - minX
let height = maxY - minY

/// Rect to crop
let rect = CGRect(x: minX, y: minY, width: width, height: height)

guard let croppedImage = cropImage(
editableImage,
toRect: rect,
viewWidth: imageFrame.width,
viewHeight: imageFrame.height
) else {
Log.warning("Could not crop the image", error: LogError.warning)
return
}

/// imageView image update
imageView.image = nil
imageView.image = croppedImage
editableImage = croppedImage

/// Reset the default crop rectangle due to rotation
setupDefaultCropRectangle()
}

/// Returns the cropped image for given rect
func cropImage(_ inputImage: UIImage, toRect cropRect: CGRect, viewWidth: CGFloat, viewHeight: CGFloat) -> UIImage? {
let imageViewScale = max(inputImage.size.width / viewWidth, inputImage.size.height / viewHeight)

/// Scale cropRect to handle images larger than shown-on-screen size
let cropZone = CGRect(
x: cropRect.origin.x * imageViewScale,
y: cropRect.origin.y * imageViewScale,
width: cropRect.size.width * imageViewScale,
height: cropRect.size.height * imageViewScale
)

guard let cutImageRef: CGImage = inputImage.cgImage?.cropping(to: cropZone) else {
Log.warning("Crop failed", error: LogError.warning)
return nil
}

return UIImage(cgImage: cutImageRef)
}

The cropImage() function performs the crop for the given rect using the CGImage function cropping. In cropAction() we first calculate the vertical and horizontal insets, which are the half of the vertical or horizontal empty spaces that the image has inside the imageView. Refer to the image Update image frame illustration for better understanding. After that, we calculate the rectangle.

We have four CGPoints to calculate the final rect we want to crop. But we need to choose which of the buttons defines which value for the final rect. For example, one of the buttons topLeftButton or bottomLeftButton has the minimum x value. So we use min and max functions to make sure we have the correct minX, maxX, minY and maxY to set the final rect we want to crop.

This is what it looks like after we have performed the crop action:

UI after adding crop functionality
UI after adding crop functionality

See the full code in our GitHub Repository:

Conclusion

In conclusion, the component introduced in this blog post offers a robust solution to the absence of a native image editing component in iOS. By integrating this component into your app, you can enhance the user experience with powerful, yet simple-to-use image editing capabilities. We’ve walked through its features, implementation, and potential impact on your app’s functionality, ensuring that you can leverage this component to its fullest potential. Embrace this solution and elevate your app’s image editing capabilities to meet and exceed user expectations.
(by Alper Kocaatli)

--

--

Mobile@Exxeta

Passionate people @ Exxeta. Various topics around building great solutions for mobile devices. We enjoy: creating | sharing | exchanging. mobile@exxeta.com