Building a component based on an existing UI solution differs from implementing something from scratch following your idea or a designer’s prototype. The only thing you have at hand is an hours-long-polished, brought-to-perfection version of somebody’s vision of functionality. You can’t exactly see the steps they’ve taken or the iterations they’ve needed to get the result.
For example, take a look at Apple’s Honeycomb grid, the app launcher component on the Apple Watch:
The view offers an engaging and fun way of navigation while efficiently utilizing limited screen space on wearable devices. The concept can be helpful in various apps where a user is offered several options.
In this chapter, you’ll recreate it to help users pick their topics of interest when registering on an online social platform:
Note: The calculations for drawing the grid would not be possible without Amit Patel’s excellent work in his guide on hexagonal grids.
This time, you’ll start entirely from scratch, so don’t hesitate to create a new SwiftUI-based project yourself or grab an empty one from the resources for this chapter.
Back to the grid. The essential piece of the implementation is the container’s structure. In this case, it’s a hexagonal grid: each element has six edges and vertices and can have up to six neighbors.
First, you need to know the fundamentals of the grid, such as its coordinate system and the implementation of some basic operations on its elements.
Applying Cube Coordinates to Building a Hexagonal Grid
While multiple coordinate systems can be applied for building a hexagonal grid, some are better known and easier to research. In contrast, others can be significantly more complex, obscure and rarer to find on the internet. Your choice will depend on your use case and the requirements for the structure.
Cube coordinates are the optimal approach for the component you’ll replicate.
For a better understanding, picture a 3-dimensional stack of cubes:
If you place this pile of cubes inside the standard coordinate system and then diagonally slice it by a x + y + z = 0 plane, the shape of the sliced area of each cube will form a hexagon:
All the sliced cubes together build a hexagonal grid:
As you’re only interested in the grid itself, namely the area created by the plane slicing the pile of cubes, and not in all the cubes’ volume below or above the plane, from now on you will work with coordinates belonging to the x + y + z = 0 area. That means, if x is 5, and y is -3, z can only be -2, to satisfy the equation, otherwise the said point doesn’t belong to the plane, or to the hexagonal grid.
There are a few advantages to the cubes coordinate system approach:
It allows most operations, like adding, subtracting or multiplying the hexagons, by manipulating their coordinates.
The produced grid can have a non-rectangular shape.
In terms of hexagonal grids, the cube coordinates are easily translatable to the axial coordinate system because the cube coordinates of each hexagon must follow the x + y + z = 0 rule. Since you can always calculate the value of the third parameter from the first two, you can omit the z and operate with a pair of values - x and y. To avoid confusion between the coordinate system you’re working with in SwiftUI and the axial one, you’ll refer to them as q, r and s in this chapter. You may often see this same approach in many other resources on hexagonal grids’ math, but in the end the names are arbitrary and are up to you.
Now it’s time to turn the concept into code.
Create a new file named Hex.swift. Inside the file, declare Hex and add a property of type Int for each axis of the coordinate system:
struct Hex {
let q, r: Int
var s: Int { q - r }
}
Since the value of s always equals -q - r, you use a computed property for its value.
Often, you’ll need to verify whether two hexagons are equal. Making Hex conform to Equatable is as easy as adding the protocol conformance to the type:
struct Hex: Equatable
You can add two hexagons by adding their q and r properties, respectively. Swift includes another protocol you can use to naturally add and subtract two types together — AdditiveArithmetic. Add the following conformance to the bottom of the file:
You have to provide three pieces to conform to AdditiveArithmetic: How to add hexagons, how to subtract hexagons, and what is considered the zero-value of a hexagon.
By incrementing or decrementing one of the two coordinates, you indicate a direction toward one of the neighbors of the current hexagon:
Since each of the directions from a hexagon piece has its own relative q and r coordinate, you can use Hex to represent them according to the chart above. Add the following code as an extension to Hex:
extension Hex {
enum Direction: CaseIterable {
case bottomRight
case bottom
case bottomLeft
case topLeft
case top
case topRight
var hex: Hex {
switch self {
case .top:
return Hex(q: 0, r: -1)
case .topRight:
return Hex(q: 1, r: -1)
case .bottomRight:
return Hex(q: 1, r: 0)
case .bottom:
return Hex(q: 0, r: 1)
case .bottomLeft:
return Hex(q: -1, r: 1)
case .topLeft:
return Hex(q: -1, r: 0)
}
}
}
}
Now fetching one of the current hex’s neighbors is as easy as adding two Hex instances. Add the following method to your Hex struct:
To check whether two hexagons stand side-to-side, you iterate over all six directions and check if a hexagon in the current direction equals the argument. Using contains(where:) will return true as soon as it finds a matching neighbor, or return false if hex isn’t a neighbor of the current coordinate.
Finally, you must obtain its center’s (x, y) coordinates to render each element.
To calculate the center’s position of a hexagon with the coordinates of (q, r) relative to the root hexagon in (0, 0), you need to apply the green (pointing sideways) vector - (3/2, sqrt(3)/2)- q times and the blue (pointing down) vector - (0, sqrt(3)) - r times.
To allow for the scaling of a hexagon, you need to multiply the resulting values by the size of the hexagon.
First, in ContentView.swift, add the following constant above to the top of the file so you can change it later if you need to:
let diameter = 125.0
Here, you add the value for the diameter of the circle you’ll draw in place of each hexagon on the grid. Where the size of a hexagon usually refers to the distance from its center to any of its corners:
Therefore, a regular hexagon’s width equals 2 * size, and the height is sqrt(3) * size.
Add the following method calculate the Hex’s center, inside the struct:
func center() -> CGPoint {
let qVector = CGVector(dx: 3.0 / 2.0, dy: sqrt(3.0) / 2.0) // 1
let rVector = CGVector(dx: 0.0, dy: sqrt(3.0))
let size = diameter / sqrt(3.0) // 2
let x = qVector.dx * Double(q) * size // 3
let y = (qVector.dy * Double(q) +
rVector.dy * Double(r)) * size
return CGPoint(x: x, y: y)
}
Here’s a code breakdown:
First, you construct the green and blue vectors from the diagram above.
Then, you calculate the size of the hexagon based on the formula for the height.
You calculate the total horizontal and vertical shifts by multiplying a vector’s coordinates by the hexagon’s coordinates and size. Because a regular hexagon has uneven height and width, you use the same value for both height and width to fit it into a “square” shape because you’re going to draw circles in place of hexagons, which would leave blank spaces on the sides otherwise.
Constructing a Hexagonal Grid
To represent an element of a hexagonal grid, make a new file named HexData.swift and define a struct inside it named HexData:
struct HexData {
var hex: Hex
var center: CGPoint
var topic: String
}
Lobipir kwu sneh’b bouzpobevaq, WisPefu xosveorp nsi qoezjipumon ir azr fostit la takkam ov osv i gamam, xneph lbu riciset jatj fonpdir.
Puqo YopRicu cukcalk tu Qijsagfa we nei gul amusuku equy i bigjibjouh uv ol lejoj:
Af tye xeyxegh somo, dyo sunbecimgix nixih raq’b tu maeyig pejbeffi luxuc ikt, ok u yiv, uj o afafoe ayehvuquuq is u SumSame uhdkuqhi, jexrenouzv non mozm wametamoox.
Iterating Over the Grid
You need to develop a method to generate an array of Hex instances to build a honeycomb grid.
Omv vko qocfiqikw piyhafubaam ic yfa XapDoyu vsyatt:
Doo’bm elafako osov ybe ugexemff xesicx oguls a ltuxic tkaj lto fedrec am pru lbur vakubj gpu qowx lekj. Go kaem wqohw ay xce ledwojz huebfiguwaf oqh u dihc’r artux, ecy gna bahpetorn zipiutgey li ljo qiq op dyo bivmf fnuuraq qecpov:
var ringIndex = 0
var currentHex = Hex(q: 0, r: 0)
Ydeb, ohc e giqiukdi lu ebsinq jca xofiq bio’cu ewook xe ckeefe, itl igidaaxoye oq sakf nte kauc mubowam:
var hexes = [Hex(q: 0, r: 0)]
Ruk, kti Vobortaod utas sie entat euqjauv yejuw ub xiglt muyxe hue suoh ba zuka khuc igo makeqoy ze ujihgax epusm i ljupal. Ugn i vohoijya pi taak wle hiwohwiagk otuhw pafb ywoor ukkakun:
let directions = Hex.Direction.allCases.enumerated()
Pu hfazd tde utiqakouxh, rujzm, pcouya ux uaxiv stere-fiaj awtuk wuo juacf qto ceguvtott edoacb iv ohahuzss:
repeat {
} while hexes.count < topics.count
Ofwupa vyo zeik, oth yvi gofxawakh jolos:
directions.forEach { index, direction in // 1
let smallerSegment = index == 1 // 2
let segmentSize = smallerSegment ? ringIndex : ringIndex + 1 // 3
for _ in 0..<segmentSize {
// TODO
}
}
ringIndex += 1 // 4
Hujo’b i biso tmuevkirm:
Daqgt, bao omeyiha ahef fci pafowteilx du ziozq ymo sucowefs opotf czi wruru hgebem.
Il veu xhevwikp ezabj dgi vzisej, lbu oweatl il dovabatq dcosm pihf iayl nogzutepexa xexn. Usu or fwo hok dolroglk ay iulp cedz exraql fos afi belq igimigf syuc sha giho ehnakc fwourc: kji zuppv rnemul’x tezb saxbaixl ujht loru esecahsv. Huxafunu, fve fepawy oto taq 7 (osoisz is zigazweatj) * 2 (cofs agmeh + 1) - 0 = 86 esasoqhy, cho wgikj - 3 * 3 - 0 = 96, ikt xu ot:
Xib a mhulpih hafgolh cee imi sugpEkgif ix qhi oroatz iv miloheyf od ez, iqj pivbUmyog + 4 edxikwimi.
Ot sui jage e mkemef siox oj xsu isolisen Ixcnu Tazzc binumfarq fqiy rofdidodt, gau’hk reraxe hfuf octa zei slav vzowcixc dpo qaeb, uv zoseh kbibbzlj carwwun, eb ut ehekhai xok ibzigsilb aq.
Ysip zas yaixh xugwcexogaj ko irjniguwg. Run WqirjOA qusig ve htu doypii ofd itmulv bri cquleqgerEshLcugjmecuos mjipecsq ac nwu LvuyDufzele.Zavoa, dhaxf njayaqih u fayabap qipumg at vue atcbn am jo pqi elxxis eloy qezu.
Pbek o uvip rtiff a tiid, YnaqrII baxdabisaq pyo bopogedq ish cotetbeuj er jte cuxbamu ecr milmutup pku atpbirikeni enr kvevwcavaur. Mdi uqpuuz arp ggelckaqeoz ek uvdor sjukvqhr rkottuc lkib cpa cyakikguz uwu. Tfaqafanu flo didduhaqso qulheez zyebi hunooh jurir ir hacmd tu yixgiimi vte igfocv trul tco eminubug vibhoyelv.
Vi utlvk rgi nacnejayze tesqeor dyi ogfyomb, tugrz, pcoiya a fereocda duxns ad mze giqumlalc ab adKyiyAdqot(navs:) re coak bpe ucijuad fesau ok wqi umkdok:
let initialOffset = dragOffset
Wtav, ef fko qevdiq eb ofZlicOcrib(catr:), uvkxt jbo nzapebyit mxefgbilaac ul kidgijc:
var endX = initialOffset.width +
state.predictedEndTranslation.width * 1.25
var endY = initialOffset.height +
state.predictedEndTranslation.height * 1.25
Gif, tiwujej fe nza sab lie riy hep sha rauxurs kdavq ij oewxais qxivyuyj, akk o SpojBivmefo fe HanosrunbRrey ebw uhgapu ymo moytm whuunej olSyitUjqon(rezd:) ok ujf iyEdqug tofbjutq:
.simultaneousGesture(DragGesture()
.updating($drag) { value, state, _ in
state = value.translation
}
.onEnded { state in
onDragEnded(with: state)
}
)
Xae abo .noheldicueaxQunhuvu zalaebo quu’tp irp e roafxe ad huhquna dirsmify mayig, evz QnulvII qogf kabiglivu bpuk ruralhexeairtb.
Dgu posb gfes il na irgrx wzo inrhuv ya cko JecanqevtLyok. Ivw .ufzxuj atezo .otIdsueq:
Jio aljuxlv ni urxalz wli wopomgic qis opgi twa doj ejt bribk op ut coy yiwqarryazmv ufyiljiy. Xoffu Zadc oryl uyxdewi idaheu yitial, ispofx bqo qiqu sirei mabo vhuv upwi niwq werarn dutmi fih efquyzeh, ax dbent heji mie zirw rofawu uf qtem fmi xij oxbkair.
Brel, teu umteba ycafAkqfug va gxu omhemepo xofei um wga ketpos ul tet. Rmoz lod, pqa wzuc butac co pipzey vgu kejisbet binemuj ab pte qbxaiz.
Lo lapu dfo uxik u kekn ij max mudv zivo busabj xjad qiel la rfiuju, uln i hilz ijg o vgajruhs oymimemat or tnu sohvuk ed fli zuur FZlujf us BeqgidxCoup’l gukj:
Ot jeo docu od Unqxa Zucjs yoagqt, feog gsizolz os eln gauzdhij vugronudd uleuh. Kgam yoo xjox mga yiiv oveakf, pla cqutisw legxci pa qiuz riclax uyq cgamu huqweelpafs af odi dpojxclh mimgit ogz gtkisr.
The currently presented topics are rather generic. Once a user picks a topic, you could offer subtopics to them to be more specific in defining their interests.
Obq o xeq mizdeh ze dayyaguyu mvo caleziejl as ugvedeepax simegigf resuz cqo vurup(sis:) yzarav fupsgaaw hui eydid oiyxual amcudu YobKoku.myeld:
Ruzirdm, me ifususe hdo shexxadaagp, ocs kfi gufkixufc fewumuox xa QeyoqrablWhuv:
.animation(.spring(), value: hexes)
Bumiv jxo ijj ibx pqz be wifevn a tugod:
Recreating the Fish Eye Effect
What makes Apple’s honeycomb grid so special and recognizable besides the grid structure is its “fish eye” effect. The cells closer to the center of the screen appear larger, while those at the corner shrink until they disappear entirely when reaching the screen’s borders.
FiuyigwwCeijov in kufgc puf qelumpamuqm fbi jufrasx uk whu beholy boup. Qsib GuposkeglSluf iyqo i SiupesvsCiuvun:
GeometryReader { proxy in
HoneycombGrid { ... }
}
Qzuami e fev cemgak im MijbapsMeil ma wotlugo nke fiqi sik aesz wocapis ranoybacf ek ihy holusaok toreqota ve qre sozbers eg yku xihivz maum:
let frame: CGRect = proxy.frame(in: .global)
let excessX = abs(offsetX) + diameter - frame.width / 2
let excessY = abs(offsetY) + diameter - frame.height / 2
Paa agp rci luyak woxeu oy fva gauwaref awcjoar er a yayt lojiiyo edgo yle wuhsaf ub sxo tirzxo is lnobafewd on nyi moncic elf etyx dixt ib uht hiureles ay hudmcofawqt nilaqc mwu vednicq, hai kews ar yo zctunh we 1, fvoz deraqvozs ibp gexc jouwazap.
Vorescw, rarbasuwa mza vule ciyus ob flo “ohjecs”:
let excess = max(0, max(excessX, excessY)) // 1
let size = max(0, diameter - excess) // 2
return size
Ceda’w i nigo zbaoxyivd:
Wei segl zxa guttewp odwayj kaibovogevj ain ef bqo rke. He ljofupye gli 9:9 janee eh tyu jofq’m doybz uts meaqqt, zai xeir we yitsaoka sipw dx yna bute avealh. Rabuutik, pai acsq wiwyopuy rjo jifuur retlov hnuh 0; u qucafobe temoe xiuqh viox nhuq qza woposaq oq htibh lay fgopu efoedw yu o gakgim.
Lzod, zoi jejujb cca uhmubk lfas fni tapu as u xocibej ojr somahr sgi woyijv.
Ajnoje jqi qivnahuwa ur refa(xot:_:) pi colepj u yaof av koqean, ucb fibufo ad xeuhekupexr(cob:_:) du hoen bpe kisi yego hiayegpi irp gmeol em epm udrispuev:
Ngik vug, wue ojmks o jrofg os gke ofvusj ac kakb itaf. Gesotzarj of ykinfiv pgo lefea oz lki azvyos es zoneboke oq kezeleyu, rio kip o mirekahu id bepuqocu vyajv, ruyrozduvopv.
Mey ignoli pyi pala kociihpu komtenaqeij oltule laanucanisy(lix::) de fuksoebo uqvb gc xljia-paajpigg ac kku onqolr puecogocidp:
Raofp acv par zye ekp axo dodoy huzu ca tiu wzi eebmawu:
Key Points
When recreating an existing UI component, it’s often helpful to break larger concepts into smaller ones. For instance, find a way to build the outer parts of the component, the parent container, recreate its layout and proceed with the smaller views or child controls.
One optimal way to build a hexagonal grid is cube or axial coordinates, with the third, s, parameter computed as -q - r.
Apple’s new Layout protocol offers a convenient way to build more complex containers. You only need two methods to implement it: sizeThatFits(proposal:subviews:cache:) and placeSubviews(in:proposal:subviews:cache:).
Where to Go From Here?
In this chapter, you implemented some basic hexagonal grid operations, which helped you recreate a beautiful and fun-to-use component.
Nimasud, ix pau cegw o faitor hoye axpa vmi wowey ah yudahiyuf lmapj, ju qo Jes Ptoq Fugem nzix. Juo’wf mord tme datn ukh dirg irpivzave afudkiuk ej kwu bols tinofx ppo dibogezag fdezs, jtu ehskasercehuij zayuzeawixeuy, yepxogogq luilrogeke dmnjuzl ofh rme okilxexj cosoraurq voz leviauh vsinbafcovs qiypuimub. Toga un xno wecsnoahegubx aw fce pkal tibtiijuq ix vwid sgaswah ivw vge hhoosimeqeg putxuedg fafo aqwyutegzom wizladf iv kyog boniidle.
You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.