<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[JoomTech - Medium]]></title>
        <description><![CDATA[Joom is an international group of e-commerce companies founded in June 2016 in Riga, Latvia. Joom currently includes the following businesses: Joom Marketplace, Onfy and JoomPro. - Medium]]></description>
        <link>https://medium.com/joomtech?source=rss----4a13099a91de---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>JoomTech - Medium</title>
            <link>https://medium.com/joomtech?source=rss----4a13099a91de---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 25 Jun 2026 19:55:06 GMT</lastBuildDate>
        <atom:link href="https://medium.com/feed/joomtech" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[The battle for an IDE-friendly stack trace in Go (with and without Bazel)]]></title>
            <link>https://medium.com/joomtech/the-battle-for-an-ide-friendly-stack-trace-in-go-with-and-without-bazel-9c5e3328020?source=rss----4a13099a91de---4</link>
            <guid isPermaLink="false">https://medium.com/p/9c5e3328020</guid>
            <dc:creator><![CDATA[Joom]]></dc:creator>
            <pubDate>Tue, 23 May 2023 09:00:14 GMT</pubDate>
            <atom:updated>2023-05-23T09:00:14.733Z</atom:updated>
            <content:encoded><![CDATA[<p>Author: Artem Navrotskiy</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0_JynEnlep-zNQRmidgSAg.png" /></figure><p>Developing software is about more than just writing code - you have to debug as well. So why not make debugging as painless as possible?</p><p>With some errors, we write the stack trace to the log. The IDE we use (Idea, GoLand) allows for convenient file navigation using a copied call stack (<a href="https://www.jetbrains.com/help/idea/analyzing-external-stacktraces.html">Analyze external stack traces</a>). Unfortunately, this feature only works well if the binary is built on the same host that the IDE is running on.</p><p>This post is about how we tried to create synergy between the call stack format and the IDE.</p><h3>What kind of stack display options does Go Build provide?</h3><p>There are two controls in Go Build for influencing the stack output format:</p><ul><li>the -trimpath flag: causes the display of the call stack to be the same, regardless of the local location of the files;</li><li>the GOROOT_FINAL environment variable: lets you replace the prefix to system libraries on a stack when the -trimpath flag is off.</li></ul><h3>A program for comparing stack trace output</h3><p>Let’s consider stack mapping using the example of a small program. The source code can be downloaded <a href="https://github.com/bozaro/go-stack-trace">here</a>, and here’s the program (stacktrace/main.go):</p><pre>package main<br><br>import (<br>	&quot;fmt&quot;<br><br>	&quot;github.com/Masterminds/cookoo&quot;<br>	&quot;github.com/pkg/errors&quot;<br>)<br><br>func main() {<br>	// Build a new Cookoo app.<br>	registry, router, context := cookoo.Cookoo()<br>	// Fill the registry.<br>	registry.AddRoutes(<br>		cookoo.Route{<br>			Name: &quot;TEST&quot;,<br>			Help: &quot;A test route&quot;,<br>			Does: cookoo.Tasks{<br>				cookoo.Cmd{<br>					Name: &quot;hi&quot;,<br>					Fn:   HelloWorld,<br>				},<br>			},<br>		},<br>	)<br>	// Execute the route.<br>	router.HandleRequest(&quot;TEST&quot;, context, false)<br>}<br><br>func HelloWorld(cxt cookoo.Context, params *cookoo.Params) (interface{}, cookoo.Interrupt) {<br>	fmt.Printf(&quot;%+v\\n&quot;, errors.New(&quot;Hello World&quot;))<br>	return true, nil<br>}</pre><p>And then let’s add a little go.mod:</p><pre>module github.com/bozaro/go-stack-trace<br><br>go 1.20<br><br>require (<br>	github.com/Masterminds/cookoo v1.3.0<br>	github.com/pkg/errors v0.9.1<br>)</pre><h3>The tried and true GOPATH</h3><p>To keep things in perspective, let&#39;s start with some good old GOPATH.</p><p>Sample output:</p><pre>➜ GO111MODULE=off GOPATH=$(pwd) go get -d github.com/bozaro/go-stack-trace/stacktrace<br>➜ GO111MODULE=off GOPATH=$(pwd) go run github.com/bozaro/go-stack-trace/stacktrace <br>Hello World<br>main.HelloWorld<br>	/home/bozaro/gopath/src/github.com/bozaro/go-stack-trace/stacktrace/main.go:31<br>github.com/Masterminds/cookoo.(*Router).doCommand<br>	/home/bozaro/gopath/src/github.com/Masterminds/cookoo/router.go:209<br>github.com/Masterminds/cookoo.(*Router).runRoute<br>	/home/bozaro/gopath/src/github.com/Masterminds/cookoo/router.go:164<br>github.com/Masterminds/cookoo.(*Router).HandleRequest<br>	/home/bozaro/gopath/src/github.com/Masterminds/cookoo/router.go:131<br>main.main<br>	/home/bozaro/gopath/src/github.com/bozaro/go-stack-trace/stacktrace/main.go:27<br>runtime.main<br>	/usr/lib/go-1.20/src/runtime/proc.go:250<br>runtime.goexit<br>	/usr/lib/go-1.20/src/runtime/asm_amd64.s:1598</pre><p>Everything here is straightforward - we see the full paths to each file. In this case, all paths are located either in the src directory of GOROOT or in the GOPATH directory.</p><p>Unfortunately, such a stack only points to existing files if the executable is built in an environment with the same directory layout. In our case, where some developers use MacOS, and the product build environment uses Linux, this requirement is not feasible.</p><p>Luckily, there’s a -trimpath flag that removes the troublesome part of the call stack:</p><pre>➜ GO111MODULE=off GOPATH=$(pwd) go run -trimpath github.com/bozaro/go-stack-trace/stacktrace<br>Hello World<br>main.HelloWorld<br>	github.com/bozaro/go-stack-trace/stacktrace/main.go:31<br>github.com/Masterminds/cookoo.(*Router).doCommand<br>	github.com/Masterminds/cookoo/router.go:209<br>github.com/Masterminds/cookoo.(*Router).runRoute<br>	github.com/Masterminds/cookoo/router.go:164<br>github.com/Masterminds/cookoo.(*Router).HandleRequest<br>	github.com/Masterminds/cookoo/router.go:131<br>main.main<br>	github.com/bozaro/go-stack-trace/stacktrace/main.go:27<br>runtime.main<br>	runtime/proc.go:250<br>runtime.goexit<br>	runtime/asm_amd64.s:1598</pre><p>In this case, all paths will relative to either GOPATH or src in the GOROOT directory, and it ended up being a completely portable call stack format.</p><h3>Go Modules</h3><p>When using Go Modules, the behavior of the -trimpath flag changes dramatically. Let’s compare the output of the call stack without it:</p><pre>➜ git clone &lt;https://github.com/bozaro/go-stack-trace.git&gt; .<br>➜ go run ./stacktrace <br>Hello World<br>main.HelloWorld<br>	/home/bozaro/github/go-stack-trace/stacktrace/main.go:31<br>github.com/Masterminds/cookoo.(*Router).doCommand<br>	/home/bozaro/go/pkg/mod/github.com/!masterminds/cookoo@v1.3.0/router.go:209<br>github.com/Masterminds/cookoo.(*Router).runRoute<br>	/home/bozaro/go/pkg/mod/github.com/!masterminds/cookoo@v1.3.0/router.go:164<br>github.com/Masterminds/cookoo.(*Router).HandleRequest<br>	/home/bozaro/go/pkg/mod/github.com/!masterminds/cookoo@v1.3.0/router.go:131<br>main.main<br>	/home/bozaro/github/go-stack-trace/stacktrace/main.go:27<br>runtime.main<br>	/usr/lib/go-1.20/src/runtime/proc.go:250<br>runtime.goexit<br>	/usr/lib/go-1.20/src/runtime/asm_amd64.s:1598</pre><p>And a similar output with -trimpath:</p><pre>➜ go run -trimpath ./stacktrace<br>Hello World<br>main.HelloWorld<br>	github.com/bozaro/go-stack-trace/stacktrace/main.go:31<br>github.com/Masterminds/cookoo.(*Router).doCommand<br>	github.com/Masterminds/cookoo@v1.3.0/router.go:209<br>github.com/Masterminds/cookoo.(*Router).runRoute<br>	github.com/Masterminds/cookoo@v1.3.0/router.go:164<br>github.com/Masterminds/cookoo.(*Router).HandleRequest<br>	github.com/Masterminds/cookoo@v1.3.0/router.go:131<br>main.main<br>	github.com/bozaro/go-stack-trace/stacktrace/main.go:27<br>runtime.main<br>	runtime/proc.go:250<br>runtime.goexit<br>	runtime/asm_amd64.s:1598</pre><p>Without -trimpath we still see full paths to each file, while clearly tracing three types of source files:</p><ul><li>a working copy directory (in this example: $HOME/github/go-stack-trace);</li><li>GOROOT system libraries from $GOROOT/src (in this example: /usr/lib/go-1.20/src);</li><li>third-party libraries from $GOMODCACHE (in this example: $HOME/go/pkg/mod);</li></ul><p>At the same time, unlike GOPATH, the -trimpath flag does not cut off the prefix in file names:</p><ul><li>files from the current module in the working copy directory are named with the module name from go.mod as a prefix (in this example: $HOME/github/go-stack-trace → github.com/bozaro/go-stack-trace);</li><li>GOROOT system libraries from $GOROOT/src get filenames without a prefix;</li><li>third-party libraries get the module name with the version as a prefix (in this example: /home/bozaro/go/pkg/mod/github.com/!masterminds/cookoo@v1.3.0 → github.com/Masterminds/cookoo@v1.3.0, and it’s worth noting that the word Masterminds in the file path and module name is spelled differently).</li></ul><h3>Which stack trace is IDE-friendly?</h3><p>If you happen to open a project from the repository in Idea / GOROOT and try to analyse any of the above call stacks, then there won’t be any navigation through the source files:</p><ul><li>call stack options for GOPATH aren’t suitable because this mini-project uses Go Modules and has a different file layout;</li><li>the option for Go Modules without -trimpath won&#39;t work because your home directory will most likely be different from /home/bozaro;</li><li>the option for Go Modules with -trimpath won&#39;t work as it’s not supported in IDE (<a href="https://youtrack.jetbrains.com/issue/GO-13827">https://youtrack.jetbrains.com/issue/GO-13827</a>), and of all the paths that are visible on the stack, only files from the Go SDK will be the suffixes of existing files.</li></ul><p>It seems like the IDE in our case is looking for source files along the paths relative to the project directory and its parents. As a result, an acceptable format for a portable call stack is as follows:</p><pre>main.HelloWorld<br>	stacktrace/main.go:31<br>github.com/Masterminds/cookoo.(*Router).doCommand<br>	go/pkg/mod/github.com/!masterminds/cookoo@v1.3.0/router.go:209<br>github.com/Masterminds/cookoo.(*Router).runRoute<br>	go/pkg/mod/github.com/!masterminds/cookoo@v1.3.0/router.go:164<br>github.com/Masterminds/cookoo.(*Router).HandleRequest<br>	go/pkg/mod/github.com/!masterminds/cookoo@v1.3.0/router.go:131<br>main.main<br>	stacktrace/main.go:27<br>runtime.main<br>	GOROOT/src/runtime/proc.go:250<br>runtime.goexit<br>	GOROOT/src/runtime/asm_amd64.s:1598</pre><p>As a result:</p><ul><li>paths to project files are displayed relative to the project root;</li><li>as paths to third-party dependencies, the path to the module relative to $GOMODCACHE is used, but with the prefix go/pkg/mod (IDE will find this path when the project is in the home directory, and the environment variables GOPATH and GOMODCACHE have a default value);</li><li>we use GOROOT as Go SDK file prefix. This marker allows you to visually identify these files, but we failed to get the IDE to provide navigation on them.</li></ul><p>With this call stack format, the IDE recognizes all files except those from the Go SDK. The whole thing breaks if a developer locally overrides the GOPATH or GOMODCACHE environment variables, but there’s generally no need to do this.</p><h3>Getting the call stack in the right format</h3><p>This can be done using the following methods:</p><ul><li>modify debug infromation in compile time;</li><li>before output, convert the call stack to the desired format;</li><li>make an external utility that converts the call stack to the required format.</li></ul><h3>Modify debug information in compile time</h3><p>With Go Build, we cannot modify debug information in compile time to get the desired format of the stack trace.</p><h3>Stack conversion before output</h3><p>In our case, we use the library <a href="http://github.com/joomcode/errorx">github.com/joomcode/errorx</a> across the board, and it has a method for converting the call stack to the desired format before output (you can find that <a href="https://pkg.go.dev/github.com/joomcode/errorx#InitializeStackTraceTransformer">here</a>).</p><p>Converting a path from a view without -trimpath is trivial, but this method has a number of disadvantages:</p><ul><li>if the call stack is passed by this filter, then it will remain in the original format;</li><li>some places, like pprof, are guaranteed to be transmitted in their original format.</li></ul><h3>External utility</h3><p>Using an external utility greatly complicates the overall call stack parsing scenario. In our case (and in most cases), the stack was taken from the logs where it was already in the necessary format, so we didn’t seriously consider this option.</p><h3>Migration to Bazel forced me to tackle this problem again</h3><p>In general, converting a stack before log write was suitable to the Bazel build migration. But building by Bazel brought the problem to a new level.</p><h3>Stack from the binary built by Bazel</h3><pre>➜ bazel run //stacktrace <br>...<br>INFO: Running command line: bazel-bin/stacktrace/stacktrace_/stacktrace<br>Hello World<br>main.HelloWorld<br>	stacktrace/main.go:31<br>github.com/Masterminds/cookoo.(*Router).doCommand<br>	external/com_github_masterminds_cookoo/router.go:209<br>github.com/Masterminds/cookoo.(*Router).runRoute<br>	external/com_github_masterminds_cookoo/router.go:164<br>github.com/Masterminds/cookoo.(*Router).HandleRequest<br>	external/com_github_masterminds_cookoo/router.go:131<br>main.main<br>	stacktrace/main.go:27<br>runtime.main<br>	GOROOT/src/runtime/proc.go:250<br>runtime.goexit<br>	src/runtime/asm_amd64.s:1598</pre><p>We don&#39;t require developers to to use Bazel to build and run tests for a number of reasons, some of which are:</p><ul><li>we generate BUILD files with our utility and don’t want to require regeneration of files for every change (it’s fast, but not instantaneous);</li><li>Synchronization of IDE and BUILD files is rather slow.</li></ul><p>At the same time, in the call stack from Bazel:</p><ul><li>third-party libraries begin to refer to the external directory, which the IDE doesn’t see;</li><li>it’s impossible to get the path to the module in GOMODCACHE in a straightforward way - information about the module version is lost;</li><li>generated files can get a completely unexpected prefix like bazel-out/k8-fastbuild-ST-2df1151a1acb/....</li></ul><p>All of these paths refer to real files and are quite meaningful in the context of Bazel, but without full integration they only complicate the matter.</p><h3>Stack conversion before output</h3><p>Initially, they tried to assemble a set of rules that allow you to form something acceptable from the existing call stack.</p><p>To do this (through x_defs and then embed ), a separately generated file was passed to the program, which contained the correspondence of the external name to the desired prefix in the call stack.</p><p>We also made a number of transformations to process the paths of generated files. The problem became less acute, but the results were still unsatisfactory:</p><ul><li>pprof became a complete nightmare;</li><li>some of the paths were converted incorrectly;</li><li>the structure as a whole was quite complex and fragile.</li></ul><h3>External utility</h3><p>I didn’t want to have to go down this path: in addition to all the complexity and fragility when converting the stack before output, there was also the problem of putting information to this utility that we integrated into the executable file, namely, matching the external name to the desired prefix in the call stack.</p><p>That is, it seemed necessary to do a stack trace deobfuscator. The problem is that we would obfuscate the code only from ourselves.</p><h3>Modify debug information in compile time</h3><p>When using Bazel, the build is at a lower level than Go Build. We had hoped to fix the assembly in order to immediately have convenient paths to files. The $(go env GOTOOLDIR)/compile utility also has a -trimpath option, but this parameter is no longer a boolean flag, but rather a list for replacing prefixes.</p><p>As a result, we added additional attributes to the go_library and go_repository rules so that we could set stack trace prefix for the library:</p><ul><li><a href="https://github.com/bazelbuild/rules_go/pull/3307">https://github.com/bazelbuild/rules_go/pull/3307</a></li><li><a href="https://github.com/bazelbuild/bazel-gazelle/pull/1379">https://github.com/bazelbuild/bazel-gazelle/pull/1379</a></li></ul><p>After these changes, you can override the path of files in the call stack, for example:</p><pre>diff --git a/deps.bzl b/deps.bzl<br>index ffe4981..d917282 100644<br>--- a/deps.bzl<br>+++ b/deps.bzl<br>@@ -5,6 +5,7 @@ def go_dependencies():<br>         name = &quot;com_github_masterminds_cookoo&quot;,<br>         importpath = &quot;github.com/Masterminds/cookoo&quot;,<br>         sum = &quot;h1:zwplWkfGEd4NxiL0iZHh5Jh1o25SUJTKWLfv2FkXh6o=&quot;,<br>+        stackpath = &quot;go/pkg/mod/github.com/!masterminds/cookoo@v1.3.0&quot;,<br>         version = &quot;v1.3.0&quot;,<br>     )<br>     go_repository(<br>@@ -12,4 +13,5 @@ def go_dependencies():<br>         importpath = &quot;github.com/pkg/errors&quot;,<br>         sum = &quot;h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=&quot;,<br>         version = &quot;v0.9.1&quot;,<br>+        stackpath = &quot;go/pkg/mod/github.com/pkg/errors@v0.9.1&quot;,<br>     )</pre><p>A sample output in bazel branch:</p><pre>➜ git checkout bazel<br>➜ bazel run //stacktrace<br>INFO: Running command line: bazel-bin/stacktrace/stacktrace_/stacktrace<br>Hello World<br>main.HelloWorld<br>	stacktrace/main.go:31<br>github.com/Masterminds/cookoo.(*Router).doCommand<br>	go/pkg/mod/github.com/!masterminds/cookoo@v1.3.0/router.go:209<br>github.com/Masterminds/cookoo.(*Router).runRoute<br>	go/pkg/mod/github.com/!masterminds/cookoo@v1.3.0/router.go:164<br>github.com/Masterminds/cookoo.(*Router).HandleRequest<br>	go/pkg/mod/github.com/!masterminds/cookoo@v1.3.0/router.go:131<br>main.main<br>	stacktrace/main.go:27<br>runtime.main<br>	GOROOT/src/runtime/proc.go:250<br>runtime.goexit<br>	src/runtime/asm_amd64.s:1598</pre><p><strong>NOTE:</strong> For some reason, the Gazelle patch is not picked up by itself. If an error like flag provided but not defined: -stack_path_prefix occurs while running the example, then to fix it, you need to rebuild Gazelle itself. In this case, the easiest way to reset the Bazel cache is: bazel clean --expunge &amp;&amp; bazel shutdown.</p><h3>Conclusion</h3><p>As a result, we were able to ensure that the stack trace copied from the log was correctly recognized in the IDE. At the same time, this extended to all sources of stack traces, including, for example, prof full goroutine stack dump.</p><p>To do this, we had to patch up rules_go and gazelle a little, but we hope that these changes will someday get into the upstream.</p><p>Any comments, suggestions and words of support (or condemnation) are welcome, and stories about your own experience are more than welcome!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9c5e3328020" width="1" height="1" alt=""><hr><p><a href="https://medium.com/joomtech/the-battle-for-an-ide-friendly-stack-trace-in-go-with-and-without-bazel-9c5e3328020">The battle for an IDE-friendly stack trace in Go (with and without Bazel)</a> was originally published in <a href="https://medium.com/joomtech">JoomTech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Find bottlenecks in just 30 minutes using Jaeger traces]]></title>
            <link>https://medium.com/joomtech/find-bottlenecks-in-just-30-minutes-using-jaeger-traces-903e42ea4d7a?source=rss----4a13099a91de---4</link>
            <guid isPermaLink="false">https://medium.com/p/903e42ea4d7a</guid>
            <category><![CDATA[server-optimization]]></category>
            <category><![CDATA[high-performance]]></category>
            <category><![CDATA[open-source]]></category>
            <category><![CDATA[scala]]></category>
            <dc:creator><![CDATA[Joom]]></dc:creator>
            <pubDate>Tue, 14 Mar 2023 13:46:14 GMT</pubDate>
            <atom:updated>2023-03-14T13:46:14.307Z</atom:updated>
            <content:encoded><![CDATA[<p>Author: Artem Klyukvin</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*whbB6jTCesGu8-LCsPsDOQ.jpeg" /></figure><p>Hi, My name’s Artem, and I’m a backend developer in the client backend team. One of the important parts of my job is reducing the latency of our backend. That exact issue is what inspired this article, so let’s get into it.</p><blockquote><em>In one of our checkout endpoints, the 99th percentile latency breaks the SLO. We need to fix this.</em></blockquote><p>Accordingly, the question arises: How can we quickly and accurately find the cause of slowdowns on a very low-frequency request at the 99th percentile, and what can we do to eliminate it? The answer turned out to be a library for semi-automatically searching for bottlenecks in distributed systems. You can find the relevant GitHub link at the end of the article.</p><h4>What are the standard tools for finding performance bottlenecks?</h4><p>Several of these tools are already available in the public domain.</p><ul><li>Profiling, e.g. with <a href="https://pkg.go.dev/runtime/pprof">pprof</a>.</li><li>Building dashboards with performance metrics. For example, <a href="https://prometheus.io/">Prometheus</a> + <a href="https://grafana.com/">Grafana</a>.</li><li>Studying the execution traces of the requests we are interested in. We obtain these, for example, using <a href="https://www.jaegertracing.io/">Jaeger</a>.</li></ul><p>Let’s take a closer look at these three paths , including their advantages and disadvantages.</p><h4>Profiling won’t help in a distributed system</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Rj6oSIkeR7r0SjChKh24rw.png" /></figure><p>Profiling helps with find runtime, CPU, and memory bottlenecks. In the case of the Go ecosystem, the profiler is generally available right out of the box. Therefore, if we have a dominator in the code that’s not only going to the database, but also mining crypto for a neighboring team, then they can easily be found in this way.</p><p>But a profiler will not help if we are talking about a distributed system with dozens (or even hundreds) of services written in different languages. In such systems, it’s not always possible to uniquely identify the problematic service that needs to be profiled. Therefore, we want to have a tool that can analyse the performance of the entire system as a whole and identify problems across all services at once.</p><p>Such a tool is super useful if you need to speed up the backend, but the problem becomes responsibility of some other team. In this scenario, the work goes much more smoothly if the task is formulated as “John, your service slows down on request X to base Y — try to speed it up, please”, and not “John, it seems that your service is slowing down somewhere — make it faster.”</p><p>In our case, when accelerating a request, it turned out that the neighboring service was slowed down due to the fact that the data was in an overloaded database. Migration to a nearby base should help, and since we already knew exactly what to do, the team responsible for this service was able to complete the migration. As a result, optimisation was completed fairly quickly.</p><p>Another problem with profilers is that we only get information about how the service performs in general. That is, if we have some critical, but low-frequency request that doesn’t make a serious contribution to the total resource consumption, we won’t be able to understand how it can be accelerated using just the profiler.</p><p>For example, in our backend, some of the most frequent requests are requests for a product card and the main page. Any bottlenecks in these areas will stand out and easily be found! On the other hand, shopping carts, checkouts and user profiles are requested thousands of times less often, meaning even if these queries have very serious performance problems, they will be almost invisible on profiles. This is especially the case if these problems appear only at higher percentiles (for example, p95 or p99). In this case, getting the profiler to answer the question “What slows down in a certain query at a certain percentile?” is not possible at all.</p><p>Long story short: Profiling can help find global bottlenecks in a particular service, but this tool is not suited to identifying problems in specific user scenarios in distributed systems.</p><h4>Dashboards with performance metrics — you can’t please everyone</h4><p>If you already have a dashboard that shows the latency of the problem area, that’s great! But the fact is that in order to build such a dashboard, you first have to find the problem. Building each dashboard requires some effort, and building them in advance for all occasions is more or less unrealistic.</p><p>Returning to the example from the previous section, it’s highly improbable that someone would build a dashboard for a very specific request to the exact collection in the database given there are hundreds of collections there. But even if such a dashboard suddenly appeared, how would you know to check it?</p><p>Therefore, instead of a bunch of disparate dashboards, it’s better to have a tool that would passively do the working of collecting all the information (more or less) necessary for performance analysis, and then would allow us to make queries of arbitrary complexity to the collected data and highlight probable problems.</p><h4>Manual viewing of Jaeger traces is great, but something is missing</h4><p>Jaeger traces are tree structures, each node of which contains the execution time of the corresponding method in the process of processing a request.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Qa9O8Hp9kiVTqCxvfXlPfA.png" /></figure><p>Collecting such traces allows you to eliminate all the previously mentioned disadvantages of profilers and dashboards.</p><ul><li>The trace has parameters such as endpoint and execution time. We can focus on just the requests we really need.</li><li>The trace may contain information about all services involved in processing a request. We can at a glance cover a distributed system with an arbitrary number of components.</li><li>Traces are collected passively. If we decide to see why a shopping cart slows down for us, we don’t need to start wrapping it with metrics and building a new dashboard — simply ask Jaeger for the appropriate traces.</li></ul><p>But unfortunately, this tool also has problems.</p><ul><li>Long traces (up to several thousand spans). Problems can be difficult to spot by eye.</li><li>On the higher percentiles (p95, p99) things are tricky. From single traces it’s difficult to understand what slows down the most — the slowdown can be different for different traces, such as the database, the cache, the search, etc. It can be difficult to understand where to concentrate your efforts.</li><li>Jaeger’s UI does not allow you to conveniently look at the latency of individual subsystems. You have to open the entire trace, and only then look for the necessary calls.</li><li>There is no way to estimate how much the proposed optimisations will improve the performance of the entire system as a whole.</li></ul><p>Returning to the example from the section on profiling — although the slowdowns in that collection were the main problem, we didn’t find long queries on every trace. It was also difficult to unequivocally decide if it was worth focusing efforts on collection migration as opposed to, for example, optimising requests to Redis.</p><h4>Our solution — Jaeger + Spark</h4><p>Of all the ways to find bottlenecks, the analysis of Jaeger traces seemed to be the most promising. But in order not to have to deal with viewing single traces manually, we decided to upload them and analyse them using third-party tools.</p><p>When the question arose of how to build some kind of aggregated statistics on top of traces, the first thing that came to mind was <a href="https://spark.apache.org/">Spark</a> (spoiler: we stopped there). At Joom, we use Spark for many different tasks, such as:</p><ul><li>building dashboards based on client analytics events,</li><li>processing of historical logs,</li><li>building pipelines for our ML teams.</li></ul><p>So, in order to analyse traces in Spark, you must first upload them. To do this, we used Jaeger-collector (the service responsible for forwarding traces to long-term storage) in order to use <a href="https://kafka.apache.org/">Kafka</a> as secondary storage. Next, the periodic job extracts traces from Kafka and stores them in S3, and from there we can read them in our spark jobs.</p><p>Now the traces are at our disposal, and all that remains is to decide what to do with them.</p><p>A search on the web for ready-made solutions for batch analysis of execution traces led us only to a single <a href="http://taoxie.cs.illinois.edu/publications/icse12-stackmine.pdf">Microsoft article</a>. But despite its effectiveness, the algorithm proposed in the article is quite difficult to implement, and also needs to be adapted to our needs.</p><p>In this regard, we set ourselves the task of writing a tool that, based on the collected traces, would be able to answer the following questions.</p><ol><li>I have a set of requests of a certain endpoint with a certain duration. Which calls in this slice takes the most time?</li><li>If I manage to speed up some calls (by a percentage or by a fixed amount), then what will the latency percentiles of the corresponding slices that I am interested in be?</li></ol><p>To solve the first question, we decided to follow the simplest path — we select the required trace slice and inside it summarised the durations of all spans by the name of the operation. An arbitrary string can appear as an operation name. For example, the full name of the method. After that, we can sort in descending order the execution time of all the spans and understand which one is potentially the most difficult. This is a rather rough estimate, but for our purposes, it still turned out to be quite useful.</p><p>With the second question, everything turned out to be somewhat more complicated, since calls can be performed both sequentially and in parallel. Let’s say that a call to a third-party service takes 100 ms. At best, a 95% speedup here can at best speed up the entire query by 95 ms overall.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/536/1*SE_TTewLdnQnJ0uPRbleKA.png" /></figure><p>And at worst, it won’t speed up at all if the trip to this service was done in parallel with an even longer request.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/595/1*F4cC6CLrRqMrqrcCCqZTZw.png" /></figure><p>In a trivial case, we can just look into the codebase and find out exactly how the request is made in relation to all other requests in the trace. But if our codebase is big enough, it’s easy to miss running a new goroutine six frames up the stack and waste time optimising something that won’t speed things up overall.</p><p>Therefore, when writing our library, we took a more reliable path — using Scala code, we apply hypothetical optimisation to each trace from the slice. Hypothetical optimisations can take the following forms:</p><ul><li>operation X is accelerated by Y%;</li><li>operation X is accelerated by Y ms;</li><li>all X operations after the first one are accelerated by 100% (this formulation is useful in case of caching repeated calls within one trace);</li><li>…and potentially many more.</li></ul><p>After that, based on the obtained synthetic traces, the latency distribution is constructed over the percentiles of interest for each of the synthetic optimisations. Based on the results of such a simulation, we have a fairly reliable estimate of what we’ll get from optimising the operation of one or another system component. Now you can start actually refactoring the code!</p><h4>Let’s get to work!</h4><p>Let’s use a real example to see how the approach described above helped us find a problem in one of our checkout flow requests.</p><h4>Searching for optimisation</h4><p>First of all, we look at the details of what the service does during the execution of the request.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lP2DMHMJsl1mW6gsG8Gz6g.png" /></figure><p>On this graph, operations are plotted along the horizontal axis, and the ratio of their total execution time to the total execution time of all traces in the slice is shown along the vertical axis. That is, on average, each call to the checkoutdata.GetBundle method takes 15% of the total request time. Sometimes this is enough information to start thinking about where to look for bottlenecks. If this isn’t the case, you can request the same diagram for any of the <a href="/3589f240184e41128b58b50e1bd3a5fb">child operations</a>. For example, let’s see what happens inside a checkoutdata.GetBundle call.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6NFb8m_vhz0fqvN13JL_Og.png" /></figure><p>So, we see that in the GetBundle request, a significant part of the time is spent going to the DaData address suggestion service.</p><p>Looking closely at the code, we noticed that in this request we’re not interested in the entire address as a whole — just the region is enough. That is, this query can be efficiently cached. But before jumping into the fray and screwing up caches, let’s find out how much that would actually speed up the request.</p><h4>Simulation optimisations</h4><p>Let’s say we can cache a call to the address suggestion service, and the cache cunning will be around 95%. It is necessary to evaluate how much such a cache will speed up the analysed query as a whole. For simplicity, we will assume that in all traces we speed up the call to DaData by 95% (if we want to simulate the behavior of the cache more realistically, we need to speed up the call to DaData by 100% with a probability of 95%, with a 5% probability of not speeding it up at all) . We apply this synthetic optimisation to all traces of interest to us and get the following graph.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SgOftrpD13O9XWmWSNKp0Q.png" /></figure><p>The query latency percentiles are plotted along the horizontal axis, and along the vertical axis, by what percent the corresponding percentile will accelerate when hypothetical optimisation is applied.</p><p>We see that the greatest influence of such optimisation is concentrated in the zone of lower percentiles (p75, p85). At the same time, p99 reacts relatively weakly — only by 5%. However, a 10% improvement in p50 looks very promising. Therefore, it is worth trying to add caching to this request. In reality, after we added such caching, the latency of this request on p50 only fell by about 6–7%.</p><h4>Other examples</h4><p>The example described above is just one of many where a Jaeger trace analysis library helped us.</p><p>These were some more successful optimisations:</p><ul><li>Found and neutralised an extra trip to a third-party service in the pricing module. The result was minus 6% latency on the p50 of the main screen.</li><li>Found an extra trip to the ranking system on the shares screen. The result is minus 40% of the p99 latency of this screen.</li><li>We found a trip to an overloaded base at checkout (the same one that we talked about when comparing different methods of searching for bottlenecks). The result is minus 12% of the p99 latency of this query.</li></ul><h4>Conclusion</h4><p>As a result, by applying the full power of Spark to the traces unloaded from Jaeger, it was possible to create a convenient tool that lets us search not only for potential problems in large distributed systems, but also to give a detailed assessment of the potential of optimisations before their implementation. If you also use Spark and Jaeger in your work, you may also find it useful. You can find the open code here on <a href="http://github.com/joomcode/trace-analysis">GitHub</a>. If you have any questions, please leave a comment below — I’ll be happy to answer!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=903e42ea4d7a" width="1" height="1" alt=""><hr><p><a href="https://medium.com/joomtech/find-bottlenecks-in-just-30-minutes-using-jaeger-traces-903e42ea4d7a">Find bottlenecks in just 30 minutes using Jaeger traces</a> was originally published in <a href="https://medium.com/joomtech">JoomTech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Churn Rate: What it is and how to calculate it]]></title>
            <link>https://medium.com/joomtech/churn-rate-what-it-is-and-how-to-calculate-it-bb60cee751f0?source=rss----4a13099a91de---4</link>
            <guid isPermaLink="false">https://medium.com/p/bb60cee751f0</guid>
            <category><![CDATA[ecommerce-management]]></category>
            <category><![CDATA[mobile-app-analytics]]></category>
            <category><![CDATA[tutorial]]></category>
            <category><![CDATA[web-analytics]]></category>
            <category><![CDATA[product-management]]></category>
            <dc:creator><![CDATA[Nikolay Gusev]]></dc:creator>
            <pubDate>Tue, 07 Mar 2023 17:03:16 GMT</pubDate>
            <atom:updated>2023-03-09T10:59:16.444Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*P-jNwLu-G5K6NTgA.png" /></figure><p>In this article, we’re going to discuss Churn Rate, as well as look into the following questions:</p><ul><li>What is Churn Rate?</li><li>Why do you need this metric?</li><li>How can you calculate it using SQL?</li><li>How can you calculate it using Python?</li></ul><p>Our goal is to provide you with answers as quickly and clearly as possible as to why this metric is needed and how to calculate it correctly using various software. This article also gives examples of code that you’re free to use to generate data and calculate Churn Rate on your own.</p><h3>What is Churn Rate?</h3><p><strong>Churn Rate</strong> is a metric that allows you to answer the question: What proportion of users have stopped using a service, or in other words, have fallen off? A churned (or dropped) user is one who, since the day of their previous activity, has not logged into the service for some fixed period of time (10 days, 2 weeks, 1 month, etc.). Why is a time frame necessary? The answer is simple — we cannot wait forever to say for sure whether a particular user will return or not. For this reason, we calculate within a certain period, and:</p><ul><li>The larger the time frame, the more accurate the metric.</li><li>The smaller the time frame, the sooner we will know the result.</li></ul><p><strong>Example:</strong> On December 1, 100 users logged into our app, and we are interested in what proportion of them <strong>will not log into</strong> the service again within 28 days. Let’s say there were 17 such users → Churn Rate = 17%. (in this case, we can calculate the metric only after 28 days from the starting date).</p><h4>Calculation formula</h4><p>Churn Rate = [number of dropped users] / [number of users who were active on the selected day]</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*J1iNFLI_A5WbDbFF7J1Azw.png" /></figure><p>Returning to the previous example, we have:</p><ul><li><strong>Day X:</strong> December 1</li><li><strong>Time frame:</strong> 28 days</li><li><strong>Date X + Time frame:</strong> Current date + 28 days (until December 29)</li><li><strong>Users who were active on the selected day:</strong> 100 people who logged into the app on December 1</li><li><strong>Churned users:</strong> 17 people who logged into the app on December 1 but didn’t log in again for 28 days, i.e. until December 29</li></ul><p>It’s important to understand that <strong>Churn Rate</strong> in most cases will not be equal to <strong>1 — Retention Rate</strong> (the proportion of users who were active on date X and returned to the service after a certain period of time).</p><p>Why is it that <strong>Churn Rate</strong> ≠ <strong>1 — Retention Rate</strong>? It’s because within a given period, users don’t fall off in one big group at the same time. Here is a <strong>Retention Rate</strong> chart for different periods on date X:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*G9prhWnidDDRjKOK72gwWg.png" /></figure><p>We see that some of users were active during the Churn time frame → if a user was inactive on day 28, this does not mean that they were also absent throughout the previous period, so <strong>Churn Rate</strong> ≠ <strong>1 — Retention Rate</strong> (but if <strong>Retention Rate </strong>were cumulative, then the equality would hold).</p><p><strong>Metric calculation in practice</strong></p><p>Software:</p><ul><li>SQL — Google BigQuery</li><li>Python — Jupyter Notebook</li><li>BI — Data Studio</li></ul><p>To calculate the metrics, we will use synthetic data. We generate it like this:</p><ol><li>Import libraries</li></ol><pre>import names<br>import random<br>import datetime as dt<br>import pandas   as pd</pre><p>2. Generate an array of values for the future dataframe (may take several minutes)</p><pre># this will store all the usernames we will use<br># we will have no more than 10000 such names (they will not necessarily be unique)<br>names_list = []<br>for i in range(10000):<br>    names_list.append(names.get_full_name())<br>    <br># values to be substituted randomly<br>platforms = [&#39;ios&#39;, &#39;android&#39;]<br>columns   = [&#39;event_date&#39;, &#39;platform&#39;, &#39;name&#39;, &#39;event_type&#39;]<br>events    = [&#39;purchase&#39;, &#39;productPreview&#39;, &#39;addToCart&#39;, &#39;productClick&#39;, &#39;search&#39;, &#39;feedbackSent&#39;]<br><br># form the correspondence of the name and platform of the user<br># (for simplicity, we assume<br># that each user only uses one of the two possible platforms)<br><br>names_platform_dict = {}<br>for name in names_list:<br>    names_platform_dict[name] = random.choice(platforms)<br>    <br># form arrays of users according to the degree of uniqueness<br>regular_users     = names_list[:2000]<br>not_regular_users = names_list[2000:]<br><br># rows for the future dataframe will be stored here<br>df_values = []<br><br># main loop<br># go through all dates from December 1, 2021 to May 29, 2023<br><br>for date_index in range(180):<br>    date = dt.date(2022, 12, 1) + dt.timedelta(days=date_index)<br>    <br>    # how many lines will correspond to one day<br>    rows_number  = random.randrange(2000, 3000)<br>    <br>    for name_index in range(rows_number):<br>        # here we set the weekly seasonality<br>        # on weekdays the proportion of regular users will be higher<br><br>        if (date_index + 2) % 7 == 0 or (date_index + 3) % 7 == 0:<br>            list_of_names = random.choices([regular_users, not_regular_users], weights = [4, 1])[0]<br>        else:<br>            list_of_names = random.choices([regular_users, not_regular_users], weights = [7, 1])[0]<br>        <br>        name       = random.choice(list_of_names)<br>        event_type = random.choice(events)<br>        platform   = names_platform_dict[name]<br>        <br>        # add a row to the array of all rows for the future dataframe<br>        df_values.append([date, platform, name, event_type])</pre><p>3. Received dataframe</p><pre># create a dataframe<br>events            = pd.DataFrame(data=df_values, columns=columns)<br>events.event_date = pd.to_datetime(events.event_date)<br>events</pre><p>In our case, the original dataframe looks like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*_2FcffCsLlHX9oZMu7Z99Q.png" /></figure><p>You can also find basic information about the dataframe</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*X0zhehmTaCSgMObkhKMNfA.png" /></figure><p>4. Data upload</p><pre># In the source directory where the file with the code is located, you can find the csv file<br>events.to_csv(&#39;./example_events.csv&#39;)</pre><h4>Context</h4><p>We work for a company that sells goods via a smartphone app. The app is available on two platforms: Android and iOS. The company considers a churn metric with a time frame of 28 days.</p><h3>How to calculate Churn Rate using SQL</h3><p>We upload the data to Google BigQuery (any environment can be used).</p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/0*cwOsmhypJ66yHVF1.png" /></figure><p>Let’s move on to calculating the metrics.</p><ul><li>Since it’s customary to calculate Churn Rate with a time frame of 28 days (in the described case), it would be incorrect to calculate it for those dates from the moment of which the 28th day has not yet come. For this reason, we will cut these lines at the last stage of the query.</li><li>Since a user can have multiple events on the same day, it’s a good idea to leave only the unique matches for platform, user and date beforehand.</li><li>For each user on the date in question, we find the date of their next event, then calculate the number of days before this date. The resulting number of days will be compared with the time frame of the metric. If a user has been absent for ≥ 28 days from the date in question, they are considered “churned”.</li></ul><p>This results in the following code:</p><pre>-- find unique matches for the name, platform and date of activity<br><br>-- we only care if a user was active on a particular day<br>-- it doesn&#39;t matter what actions they performed<br><br>WITH names_days AS (<br>    SELECT DISTINCT<br>        platform,<br>        event_date,<br>        name,<br>        -- with the window function, find the date of the next user activity<br>        LEAD(<br>            event_date<br>        ) OVER(PARTITION BY name ORDER BY event_date) AS next_event_date<br>    FROM example_events<br>),<br><br>main AS (<br>    SELECT<br>        *,<br>        -- find how many days later the next user event happened<br>        DATE_DIFF(next_event_date, event_date, DAY) AS days_till_next_event<br>    FROM names_days<br>)<br><br>SELECT<br>    event_date,<br>    -- find the share of users:<br>       -- who have been absent for at least 28 days from this day out of all users<br>       -- and who were active that day<br><br>     -- you can also add a slice by platform here<br><br>     -- `distinct` below is not needed in this case,<br>     -- but with a different format of the source data, it may be needed<br><br>    COUNT(DISTINCT<br>        IF(<br>            days_till_next_event &lt; 28,<br>            NULL,<br>            name<br>        )<br>    ) / COUNT(DISTINCT name) AS churn_rate<br>FROM main<br>WHERE 1 = 1<br>    -- cut rows where 28 days have not passed since the current date <br>      AND event_date &lt;= (SELECT DATE_SUB(MAX(event_date), INTERVAL 28 DAY) FROM example_events)<br>GROUP BY 1<br>ORDER BY 1</pre><p>If there are no window functions in the version of SQL you are using, you can:</p><ul><li>Access the source table sorted by name, event_date through a subquery and apply the LEAD / LAG function without a window (providing in the SELECT clause a condition for matching the name. <strong>Example:</strong> IF(LAG(name) = name, LAG(event_date), NULL)</li><li>Number the rows in the source table sorted by name, event_date and join it to itself through table.index = table.index + 1 AND table.name = table.name</li></ul><p>At the output, we get a table in which the Churn Rate indicator is calculated for each date with a time frame of 28 days:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/484/1*Ai2a0Js-2m1r4KhFwY8G4g.png" /></figure><p>The result of the received query is not very convenient to analyse if we’re looking at the result of the SQL query in a table format. Therefore, we visualise the result using a BI tool:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*YBbR0isDo9CDYNLH.png" /></figure><p>We may also be interested in how the metric behaves depending on the chosen platform. In order to display such a graph, you need to add the additional grouping field platform to the original SQL query (also, do not forget to specify it in SELECT)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*s-szRWo9W4CAp4ph.png" /></figure><p>In both cases, seasonality is observed: on weekends, the outflow of users is higher.</p><h3>How to calculate Churn Rate using Python</h3><p>Import libraries</p><pre>import numpy             as np<br>import datetime          as dt<br>import pandas            as pd<br>import seaborn           as sns<br>import matplotlib.pyplot as plt<br>import matplotlib.ticker as mtick</pre><p>Load data from the csv file</p><pre>events = pd.read_csv(&#39;./example_events.csv&#39;, parse_dates = [&#39;event_date&#39;])</pre><p>Let’s perform similar transformations using Python. To begin with, let’s leave only unique combinations of name, platform, event_date</p><pre>events_grouped = events.groupby([&#39;event_date&#39;, &#39;platform&#39;, &#39;name&#39;])\<br>                       .agg({&#39;event_type&#39; : &#39;count&#39;})\<br>                       .reset_index()<br><br>events_grouped.columns = [&#39;event_date&#39;, &#39;platform&#39;, &#39;name&#39;, &#39;events_number&#39;]</pre><p>Now for each user, we’ll find the date of their next event. We’ll also find how many days later their next event took place</p><pre>events_grouped[&#39;next_event_date&#39;]      = events_grouped.groupby([&#39;name&#39;, &#39;platform&#39;])[&#39;event_date&#39;].shift(-1)<br>events_grouped[&#39;days_till_next_event&#39;] = (events_grouped[&#39;next_event_date&#39;] - events_grouped[&#39;event_date&#39;]) / np.timedelta64(1, &#39;D&#39;)</pre><p>For simplicity, let’s add a marker for whether we consider the user to be churned on the date in question</p><pre>events_grouped[&#39;is_churned_int&#39;] = events_grouped.days_till_next_event.apply(lambda x: 0 if x &lt; 28 else 1)</pre><p>After all the transformations, the data looks like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*x4bptctTVaHOXZ-S-zy3sg.png" /></figure><p>We now have everything we need to calculate the Churn Rate. Let’s find the maximum date from which 28 days have passed, then cut off unnecessary lines. After that, for each date, we calculate the number of unique users (DAU) and the number of those who are in the outflow. Then we calculate the share of those who are in the outflow of the total.</p><pre># we look for the maximum date for which the metric calculation can be considered correct<br>max_relevant_date = events_grouped.event_date.max() - dt.timedelta(days=28)<br><br>churn_rate = events_grouped.query(&#39;event_date &lt;= @max_relevant_date&#39;)\<br>                           .groupby([&#39;event_date&#39;])\<br>                           .agg({&#39;is_churned_int&#39; : &#39;sum&#39;, &#39;name&#39; : &#39;nunique&#39;})<br><br>churn_rate.columns = [&#39;churned&#39;, &#39;dau&#39;]<br><br># calculate the metric<br>churn_rate[&#39;churn_rate&#39;]      = churn_rate[&#39;churned&#39;] / churn_rate[&#39;dau&#39;]<br># convert it to a percentage<br>churn_rate[&#39;churn_rate_perc&#39;] = churn_rate[&#39;churn_rate&#39;] * 100</pre><p>We get the following table with our Churn Rate:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/498/1*lcRjsagFFFHmaa0YIYZUfg.png" /></figure><p>We visualise the results obtained using Python</p><pre>palette = sns.color_palette([&#39;#ff6063&#39;, &#39;#32b6aa&#39;, &#39;#ffcc53&#39;])<br><br>sns.set_palette(palette)<br><br>plt.figure(figsize=(20,10))<br>plt.grid()<br><br>ax = sns.lineplot(data=churn_rate, y=&#39;churn_rate_perc&#39;, x=&#39;event_date&#39;)<br><br>ax.set_title (&#39;Churn Rate&#39;, fontsize=15)<br>ax.set_xlabel(&#39;Date&#39;, fontsize=15)<br>ax.set_ylabel(&#39;Outflow, %&#39;, fontsize=15)<br><br>ax.set(ylim=(0, 50))<br>ax.yaxis.set_major_formatter(mtick.PercentFormatter())<br><br>plt.show()</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IqfaPLjhL38hYbKhsjTeCw.png" /></figure><p>If we want to calculate the metric separately for each platform, then that platform field must be added to the grouping fields, and also placed in the legend when plotting charts</p><pre>churn_rate_platform = events_grouped.query(&#39;event_date &lt;= @max_relevant_date&#39;)\<br>                                    .groupby([&#39;event_date&#39;, &#39;platform&#39;])\<br>                                    .agg({&#39;is_churned_int&#39; : &#39;sum&#39;, &#39;name&#39; : &#39;nunique&#39;})<br><br>churn_rate_platform.columns = [&#39;churned_names&#39;, &#39;dau&#39;]<br><br>churn_rate_platform[&#39;churn_rate&#39;]      = churn_rate_platform[&#39;churned_names&#39;] / churn_rate_platform[&#39;dau&#39;]<br>churn_rate_platform[&#39;churn_rate_perc&#39;] = churn_rate_platform[&#39;churn_rate&#39;] * 100</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*Xz1omIbqu1jzxpX5-FRpoA.png" /></figure><pre>plt.figure(figsize=(20,10))<br>plt.grid()<br><br>ax = sns.lineplot(data=churn_rate_platform, y=&#39;churn_rate_perc&#39;, x=&#39;event_date&#39;, hue=&#39;platform&#39;)<br>ax.set(ylim=(0, 50))<br>ax.yaxis.set_major_formatter(mtick.PercentFormatter())<br><br>ax.set_title (&#39;Churn Rate&#39;, fontsize=15)<br>ax.set_xlabel(&#39;Date&#39;, fontsize=15)<br>ax.set_ylabel(&#39;Outflow, %&#39;, fontsize=15)<br><br>plt.show()</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UQ42HINEcvQ7s9JqPoQDDg.png" /></figure><h3>Conclusion</h3><p>We’ve analysed a traditional method of calculating Churn Rate. In some cases, it’s more efficient to calculate this metric in a different way — it all depends on the business context and the task being solved.</p><p>Usually we are not interested in specific events, like product clicks or search queries. We just need to know whether a user was active during the time frame. Hence, Churn Rate may be calculated using a wide range of events that may be attributed to user activity. However, there are situations where certain events should be disregarded. For example, if we’re talking about search engines, which are often set in the browser as the “home page”, taking into account something from the “user opened the page” category when calculating Churn would not be very useful, because this can hardly be considered as genuine user activity.</p><h3>Possible questions</h3><ol><li>There are dates for which we still do not know whether the user logged in in the subsequent period or not (we’re talking about the metric calculation time frame). What happens if you don’t cut such lines?</li></ol><p>Starting from your current (or last available) date, minus the time frame, the metric will skyrocket. For the most recent day, its value will hit 100% since we won’t know about the subsequent activities of these users — in the calculation they will be considered as part of the outflow. The graph in this case would look something like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1eaMtciSgP8klNLpeJV0Dg.png" /></figure><p>2. But the metric doesn’t tell us who the service has truly lost forever (Actual Churn)!</p><p>That’s true, but what is “Actual Churn” anyway? We can never know for sure whether a user will log in to our service again (even if they’ve been absent from it for several years). The metric is needed in order to track a trend in dynamics, thus it will not tell us how many users we have lost forever.</p><p>3. We noticed an anomalous change in Churn Rate, what are the causes?</p><p>There can be infinitely many reasons for changes in Churn Rate, and for each area they will be different. Let’s try to cover the main ones:</p><ol><li>Advertising</li></ol><ul><li>budget reallocation;</li><li>advertising campaigns have been adjusted;</li><li>new ad channels were added.</li></ul><p>2. Product changes</p><ul><li>prices have been changed;</li><li>switched to a different type of product (application, website, widget, etc.);</li><li>Certain features have been added or removed (the app now has the ability to make calls or now it is impossible to pay for goods with certain kinds of electronic wallets).</li></ul><p>3. Other</p><ul><li>Competitors’ activity;</li><li>Influence of substitute goods;</li><li>Current events and news.</li></ul><p>It’s more important to be able to answer the following questions:</p><ol><li>On the basis of what audience is the metric calculated?</li><li>What could have caused the negative experience?</li><li>What can disturb the audience now?</li><li>How can I choose a time frame for calculating the metric?</li></ol><p>The size of the time frame used depends on your specific business and expectations. We need to understand that using a larger time frame, we will wait longer, but we will get more accurate results.</p><h3>Thank you for your attention!</h3><p>We appreciate you reading till the end! We hope the article was useful.</p><p>Should you have any questions, feel free to ask them in the comments below — we’ll be happy to answer them. Also, if you think it’s worth considering other topics related to metric construction or business analytics, let us know. We’ll try to touch on them in a future post.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bb60cee751f0" width="1" height="1" alt=""><hr><p><a href="https://medium.com/joomtech/churn-rate-what-it-is-and-how-to-calculate-it-bb60cee751f0">Churn Rate: What it is and how to calculate it</a> was originally published in <a href="https://medium.com/joomtech">JoomTech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The unexpected complexity of simple software]]></title>
            <link>https://medium.com/joomtech/the-unexpected-complexity-of-simple-software-6e14da16eeee?source=rss----4a13099a91de---4</link>
            <guid isPermaLink="false">https://medium.com/p/6e14da16eeee</guid>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[project-management]]></category>
            <category><![CDATA[complexity]]></category>
            <dc:creator><![CDATA[Tigran Saluev]]></dc:creator>
            <pubDate>Fri, 15 Jul 2022 09:41:46 GMT</pubDate>
            <atom:updated>2022-07-15T09:41:46.383Z</atom:updated>
            <content:encoded><![CDATA[<p>Again and again I face surprise when announcing an estimate of project complexity: “Why does it take so long?”, “But it’s just three easy steps!”, “But you can just take X and combine it with Y”. Programmers are used to estimating deadlines as the time to write and debug code, but there’s a lot more to implementing a big feature.</p><figure><img alt="The iceberg of software development stages. Implementation of a feature, writing tests, configuring metrics and alerts, debugging, refinement of requirements, API review &amp; approval, fixes after QA, backward compatibility, validation with A/B test, hotfixes, analytics." src="https://cdn-images-1.medium.com/max/1024/1*9JVN9gTryAcDt3YxApqG7w.png" /><figcaption><em>Did you know that icebergs in real life </em><a href="https://twitter.com/GlacialMeg/status/1362557149147058178"><em>are oriented horizontally in the water</em></a><em>, not vertically as in most stock images?</em></figcaption></figure><p>Although, even if we forget about a traditional bunch of enterprise tweaks like analytics, backward compatibility support and A/B testing and focus purely on the code in direct relation to the implemented functionality, we may see that its complexity often grows out of control.</p><p>In this article, I’m going to talk about several features that my colleagues and I have implemented in Joom at different times, from the problem definition to the details of implementation, and show how easily the seemingly simple things turn into a tangle of endlessly complex logic, requiring many iterations of development.</p><h3>Profile search</h3><p>One of the big sections of the Joom app is an internal social network where customers can write reviews of products, like and discuss them, and subscribe to each other. And what kind of a social network would it be without profile search!</p><p>Of course, search as a feature is not such a seemingly easy thing. But I already had all the necessary knowledge, and we had a ready joom-mongo-connector component which could transfer data from a MongoDB collection to Elasticsearch index, adding additional data and doing some post-processing. The task sounded pretty simple.</p><p><strong>Task</strong>. Implement an API for searching by social network profiles. No search filters — sorting by the number of followers will do for a start.</p><p>Okay, that sounds easy enough. We set up a transfer from the socialUsers collection to Elasticsearch by writing a YAML config. On the backend, we add a new API endpoint similar to the product search API, but without filters and sorting support for now (just query text and pagination). In the handler we make a simple request to Elasticsearch-cluster (the key is to get the right cluster!), then we take IDs of found documents — they are also user IDs — and convert them to the client JSON, hiding private information from prying eyes. That’s it — or is it?</p><p>The first problem we faced was transliteration. The user names were taken from social networks, where users from Eastern Europe (they were the majority at the time) often wrote them in Latin letters while their languages used Cyrillic. You may try to find “Юля”, but she’s “Julia” on Facebook, so she’s not in the search results. Similarly, you cannot find “Иван” by “Ivan”, though you’d really like to!</p><p>Here goes the first complication — during indexing, we would use Microsoft Translator API for transliteration and store two versions of first and last names. As an unpleasant side effect, the common indexing component became dependent on the transliterator client (and still is).</p><p>And the second problem, which is easy to anticipate if your native language is Russian, but it exists in other European languages as well, is the diminutive forms and abbreviations of names. If “Ivan” decides to call himself “Vanya” on Facebook, you won’t find him by querying “Ivan”, no matter how much you transliterate it.</p><p>So the next complication was that we found an index of diminutive names, added it to the code base as a hardcoded table (as easy as two thousand lines), and began to index not only the names and their transliterations, but also all the obtained diminutive forms (fun fact: in English they are officially called “hypocorisms”). We took every word in the username and looked it up in our humble table.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*z77qq3ZxTOmPeQgR4hkPPQ.png" /><figcaption><em>Official screenshot of the Joom codebase. Circa 2018.</em></figcaption></figure><p>Then we put the word out to Joom’s regional managers and asked them to find us reference books of national name abbreviations in their countries of operation. If not academic, then at least whatever they could provide. It turned out that in some languages, in addition to the tradition of having a compound name (“Juan Carlos”, “Maria Aurora”), there are also abbreviations of two, three or even four words into one (“María de las Nieves” → “Marinieves”).</p><p>This new fact made it impossible for us to make a one-word lookup. Now we had to break a sequence of words into fragments of arbitrary length, and what’s more, different breaks can lead to different abbreviations! We didn’t want to delve into the depths of linguistics and write an AI that would abbreviate a Spanish name the way it’d be abbreviated by a real Spaniard, so we sketched out (I’m so sorry, Dr. Knuth) a combinatorial search.</p><p>And, as always happens with combinatorial search, it exploded on one of the users and we had to urgently add a limit on the maximum number of generated spelling variants. This further complicated the code (which was already surprisingly complex for this kind of task).</p><h3>Machine translation of products</h3><p><strong>Task</strong>. Translate names and descriptions of products provided by the sellers in English to the user’s language.</p><p>You might have seen memes about the bizarre translation of Chinese product names. We’ve seen them too, but the desired time to market didn’t let us come up with anything better than using some existing API for translation.</p><p>It is easy to write an HTTP-client, create an account, and translate the product into the required language when it’s displayed to a user. But translations aren’t cheap, and it would be wasteful to translate the same popular product into French in each one of tens of thousands of views. Therefore, we’ve implemented caching: for each product we saved translations to the database, and when there were translations already available there, we didn’t use the translator.</p><p>But there was still room for improvement. We figured that a reasonable trade-off between translation quality and price would be to break the descriptions into sentences and cache them. After all, products often have the same patterned phrases, and constantly translating them is just wasteful. So we added another layer of abstraction to our translator component — a layer between the HTTP client and the cache (holding entire products in different languages) that breaks the text into fragments.</p><p>After release, the quality of translations was, of course, a great concern. We thought: what if we used a more expensive translation API? Would it work better with our specific texts? You can’t compare them with the naked eye, so we had to conduct an A/B test. This way we added a translation API name to our translation cache key in addition to the product ID and started requesting a translation with translation API choice depending on which A/B test group the user was in.</p><p>The expensive translator performed well, but it was still too wasteful to use it on all products. At some point, however, Joom was released in new countries where national languages were so poorly handled by our primary translator that we were ready to spend more for a successful launch; this way the logic of choosing translation API became even more complicated.</p><p>Then we decided that some of the stores on the platform are so good and the platform cares so much for their success that it’s OK to translate their products with the more expensive translator. So the logic for choosing a translator became dependent on the user, the country and the store ID as well.</p><p>Finally, we decided that our primary translator might have improved over the few years of Joom’s existence, and it could make sense to update the translation cache with some periodicity. But how to go without the A/B test? To that end, we got a “freshness” field in our cache, and things got even more complicated once again. As a result, our translation component got so incredibly complex, and this despite the fact we haven’t even enabled any homemade computational linguistics… Yet.</p><h3>Converting clothing sizes</h3><p>Perhaps one of the most painful problems when you buy clothes and shoes online is choosing the right size. When shipping from local warehouses, domestic businesses like Lamoda can simply deliver several sizes at once and just as easily take back what didn’t fit, but it doesn’t work that way with cross-borders. Parcels take a long time to deliver, the cost of each extra kilogram is high and their senders don’t expect a large flow of returning mail.</p><p>The problem gets further complicated by the fact that sellers from different countries may have completely different ideas of size. A Chinese “M” can easily turn out to be a European “XS”, and the horrendous-sounding “9XL” may not be all that different from an “XXL”. Experienced users rely on measurements, but even these are not always correct. For example, the user expects to see a measurement for chest girth, but the seller shows measurements of the garment itself — these differ by 5–10%. We don’t want the user to bother that much to shop at Joom!</p><p><strong>Task</strong>. Instead of sizes provided by sellers, show users the sizes calculated by us using some uniform conversion table based on measurements.</p><p>Okay. We take the size table, which is parsed from the product description (there’s a dedicated rocket science of 5 KLOC component to handle this) and stored in a separate field, and substitute the sizes in it with the calculated ones. Then we hardcode the table to convert the girth to size (the one we simply find on the Internet) and rejoice.</p><p>But if there is no size table in a product or there are not enough rows in it, it doesn’t work. There goes the first reason for implicitly turning off the feature on a particular product.</p><p>Hmm, the size table is supposed to show sizes for real body measurements, but most sellers provide them by measuring clothes rather than mannequins. OK, let’s tweak them by a difference factor. Product manager Rodion, the lucky owner of the perfect size “M” shirt, goes to a nearby mall, tries on a bunch of different clothes, and comes up with factors — they’re similar, but vary significantly for different kinds of clothing. For a tight turtleneck the difference is almost 0%, but for a sweater it’s 10%. Also, outerwear of the same kind may vary in fit (“slim fit”, “normal fit” and “loose fit”), and that gives a spread of another ±5%. Now our difference factor (carved in the code as the <em>Rodion coefficient</em>) consists of two multipliers.</p><p>To define the fit, we make another parser that attempts to extract it from the product’s name or description. If an item does not fall into one of the categories checked by Rodion, the feature is implicitly turned off — for the second reason.</p><p>The final touch: in a large number of products, the chest girth is listed from armpit to armpit, that is, it’s only half the girth, which leads to ridiculously small sizes. We add the logic that if the girth is less than X, well, it just can’t be, it’s obviously half the girth, and we multiply it by two. We’re lucky that adults don’t usually differ from each other in size by more than 100%.</p><p>Now everything is so complicated that when testing a feature, it’s impossible to understand why it didn’t turn on or worked out one way rather than another just by looking at the product. We’re adding a large layer of logic to the code which logs detailed reasons why the conversion is turned off. To be able to fully trace the reason for it turning off on a particular product, we have to forward the error messages up the stack, adding details for each instance, several times. The code becomes dreadful.</p><p>And it all works in various ways depending on the A/B test group, of course.</p><p>Beware of G̶r̶e̶e̶k̶s̶ ̶b̶e̶a̶r̶i̶n̶g̶ ̶g̶i̶f̶t̶s developers optimistically estimating deadlines. Estimating development time is very difficult, no matter how simple the task sounds, and surprises await at every turn!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6e14da16eeee" width="1" height="1" alt=""><hr><p><a href="https://medium.com/joomtech/the-unexpected-complexity-of-simple-software-6e14da16eeee">The unexpected complexity of simple software</a> was originally published in <a href="https://medium.com/joomtech">JoomTech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Bottom Sheet, shall we drop the formalities?]]></title>
            <link>https://medium.com/joomtech/bottom-sheet-shall-we-drop-the-formalities-400515255829?source=rss----4a13099a91de---4</link>
            <guid isPermaLink="false">https://medium.com/p/400515255829</guid>
            <category><![CDATA[swift]]></category>
            <category><![CDATA[mobile-app-development]]></category>
            <category><![CDATA[ui]]></category>
            <category><![CDATA[bottomsheet]]></category>
            <category><![CDATA[ios]]></category>
            <dc:creator><![CDATA[Mikhail Maslo]]></dc:creator>
            <pubDate>Fri, 22 Apr 2022 11:20:57 GMT</pubDate>
            <atom:updated>2022-04-22T11:20:57.046Z</atom:updated>
            <content:encoded><![CDATA[<p>At first glance, Bottom Sheet appeared all too complicated and inaccessible to me. It was a challenge! I had no idea where to begin. A bunch of questions appeared in my mind. To name a few. Should I use a view or a view controller? Auto or manual layout? How do I animate it? How do I hide it interactively?</p><p>However, everything’s changed after working on the bottom sheet in the <a href="https://apps.apple.com/us/app/joom-shopping-for-every-day/id1117424833">Joom</a> app, where it‘s used everywhere. Even in such critical scenarios as payment flow, so we’re truly confident in this component that I shared our experience on Podlodka iOS crew #7. As part of the workshop, I showed how to make a bottom sheet adapt to the content size, dismiss interactively, and support <em>UINavigationController.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6-nnSdL6_efyn51IF92MqQ.png" /></figure><p>Wait, Apple has already <a href="https://developer.apple.com/videos/play/wwdc2021/10063/">presented</a> a native way to present the <a href="https://developer.apple.com/design/human-interface-guidelines/ios/views/sheets/">bottom sheet</a>. Why write your own? Though it’s true, this component is only supported from iOS 15, which implies that it will be available only in 2–3 years. Besides, designer requirements often change and go beyond native iOS elements, so you end up with your own implementation anyway.</p><p>In this article, I want to clear the air over Bottom Sheet, answer the questions I’ve faced myself, and suggest one of the possible implementations. So after you’re through with this article, you could deservedly add the “proficient with Bottom Sheets” line to your CV.</p><p>If you’re interested, let’s get started! We’ll create a simple bottom sheet and upgrade it step by step. Along the way, you’ll:</p><ol><li>Learn how to adapt the bottom sheet to the content size and how to dismiss it.</li><li>Add interactive dismissal, taking scrollable content into account.</li><li>Support <em>UINavigationController</em> with navigation inside the bottom sheet.</li></ol><h3>Part 1. Adapting to content size. Bottom Sheet dismissing. Basic design</h3><p>First of all, let’s make sure we’re on the same page with the term “Bottom Sheet”. Bottom Sheet is a component that sits at the bottom and adapts to the size of the content. Here are some examples of its use in some system applications: <a href="https://apps.apple.com/us/app/apple-maps/id915056765">Apple Maps</a> (search), <a href="https://apps.apple.com/us/app/stocks/id1069512882">Stocks</a> (news), <a href="https://apps.apple.com/us/app/voice-memos/id1069512134">Voice Memos</a> (voice recording), etc.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BIEmPFdcUp1E16GLSyVhAA.jpeg" /><figcaption>Apple Maps, Stocks, Voice Memos</figcaption></figure><p><strong>The starter project</strong></p><p>Download the starter project via the <a href="https://github.com/joomcode/BottomSheet/tree/feature/part-1">Github link</a>. Once downloaded open <strong>BottomSheetDemo.xcodeproj</strong> and look around. You’ll see two targets in the project: <em>BottomSheetDemo</em> and <em>BottomSheet</em> — an application and a library with the bottom sheet.</p><p><em>RootViewController</em> is the first screen in the application. It has only one “Show Bottom Sheet” button which presents <em>ResizeViewController</em> on tap.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/18124815172c9de16d5ce839276a929c/href">https://medium.com/media/18124815172c9de16d5ce839276a929c/href</a></iframe><p><em>ResizeViewController</em> takes the height of the content in the initialiser. There are also four buttons that change the height of the content: by +100 and -100, by 2 and 0.5 times.</p><p>Let’s see it in action.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xCT8X_JG-M3gHmwc78dosA.gif" /><figcaption>System transition</figcaption></figure><p><strong>Theory. How do I present a Bottom Sheet?</strong></p><p>We need an entity to control the presentation, which will add a bottom sheet to the UI hierarchy, position it on the screen, account for the size of the content, respond to changes in it, take care of animation, and enable the interactive closing.</p><p>This sounds like a <a href="https://developer.apple.com/documentation/uikit/uipresentationcontroller">UIPresentationController</a>’s responsibility:</p><blockquote>From the time a view controller is presented until the time it is dismissed, UIKit uses a presentation controller to manage the presentation process for that view controller.</blockquote><p>In order to use it, we need to override <a href="https://developer.apple.com/documentation/uikit/uiviewcontroller/1621355-modalpresentationstyle">modalPresentationStyle</a> and transfer the presentation controller through <a href="https://developer.apple.com/documentation/uikit/uiviewcontroller/1621421-transitioningdelegate">transitioningDelegate</a>.</p><p>Armed with this knowledge, let’s start making the bottom sheet!</p><p><strong>Let’s create a presentation controller</strong></p><p>To show a bottom sheet, let’s override <em>modalPresentationStyle</em> and <em>transitioningDelegate</em>. Don’t forget that <em>transitioningDelegate</em> is a weak reference, and we need a strong one for not losing the object.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/c4e51846cfef48355e730ed374b21c37/href">https://medium.com/media/c4e51846cfef48355e730ed374b21c37/href</a></iframe><p>Create BottomSheetTransitioningDelegate, an implementation of<em> transitioningDelegate.</em></p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/b4ba8b5b3ce465aa30edbbd13cd021ef/href">https://medium.com/media/b4ba8b5b3ce465aa30edbbd13cd021ef/href</a></iframe><p>And presentation controller.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/fc7a4abec315e6567ada0b734e5c1c03/href">https://medium.com/media/fc7a4abec315e6567ada0b734e5c1c03/href</a></iframe><p>Finally, go back to <em>RootViewController</em> and resolve TODO.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/9b9d75024be64075a4b11c1dc16fe3ef/href">https://medium.com/media/9b9d75024be64075a4b11c1dc16fe3ef/href</a></iframe><p>Let’s run the application.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pvgWNAVC3FQHVOahya8hXw.gif" /><figcaption>Custom transition with empty presentation controller</figcaption></figure><p>Looks like it just got worse. The view controller’s opened up in full screen and hidden behind the status bar. We’ve overridden the system presentation controller that showed the view controller pretty well and positioned it according to <a href="https://developer.apple.com/documentation/uikit/uiview/positioning_content_relative_to_the_safe_area">Safe Area</a>. There is nothing like this in our presentation controller — we haven’t specified the view controller’s position in any way, not to mention Safe Area, so let’s fix that.</p><p><strong>Taking content size into account</strong></p><p>Let’s go back to <em>ResizeViewController.</em> The <em>currentHeight</em> property is self explanatory and accounts for the current height. To avoid creating excessive protocols, we’ll use <a href="https://developer.apple.com/documentation/uikit/uiviewcontroller/1621476-preferredcontentsize">preferredContentSize</a> to represent the Bottom Sheet’s desired size.</p><p>Now let’s override <em>frameOfPresentedViewInContainerView</em>, which accounts for the position of <em>presentedView </em>in the presentation controller. In our case, <em>presentedView</em> is a ResizeViewController’s<em> </em>view and <em>containerView</em> holds it.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/b4813fb1291f380682112fdd250f4eb7/href">https://medium.com/media/b4813fb1291f380682112fdd250f4eb7/href</a></iframe><p>Additionally, we set <em>shouldPresentInFullscreen</em> to <em>false</em>, because Bottom Sheet does not cover the whole screen.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/b54023a972c718f100fe796afe0dcc7e/href">https://medium.com/media/b54023a972c718f100fe796afe0dcc7e/href</a></iframe><p>Now, let’s see the result</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Tz3t7vaQmPIrQFfV_OSjiA.gif" /><figcaption>Custom transition considering <strong>preferredContentSize</strong></figcaption></figure><p>The original size is now considered, but there is no reaction to its change.</p><p><strong>Reacting to content change</strong></p><p>Let’s see <em>UIPresentationController</em>. It implements <a href="https://developer.apple.com/documentation/uikit/uicontentcontainer">UIContentContainer</a>, and thus <em>preferredContentSizeDidChange(forChildContentContainer:)</em>, which is invoked after <em>preferredContentSize </em>is changed in one of the child view controllers.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/d72cae3feb0d55388c2fa01b9c487b2a/href">https://medium.com/media/d72cae3feb0d55388c2fa01b9c487b2a/href</a></iframe><p>Here we check the current frame and the one we believe to be correct. If they are different, we refresh the <em>presentedView </em>frame<em>.</em> Let’s run the application.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Af09v1HvQD07VBq58j2s0Q.gif" /><figcaption>Size changes without animation</figcaption></figure><p>The size changes abruptly and without animation. Why? Because we didn’t include this animation in any way. Let’s add an animation on <em>preferredContentSize</em> changes in <em>ResizeViewController</em>.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/d151c9e59513e06a6442733cdca46d71/href">https://medium.com/media/d151c9e59513e06a6442733cdca46d71/href</a></iframe><p>Now, check it again.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lvqqxQCocYwgA7rPPRGZEQ.gif" /><figcaption>Animated size changes</figcaption></figure><p>It works! But here’s another problem — we can’t close it.</p><p><strong>Bottom Sheet dismissal</strong></p><p>To dismiss it, we’ll need a shadow which’ll close the bottom sheet on tap. Also, we’ll need a dismissal handler by which the presentation controller will report that it’s ready to be closed.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/cd4ccc45b51a4d6eb60605958f1ddd83/href">https://medium.com/media/cd4ccc45b51a4d6eb60605958f1ddd83/href</a></iframe><p>Let’s inject it into the presentation controller initialiser.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/7c557d63d6bc43d4fd017f04095a8a55/href">https://medium.com/media/7c557d63d6bc43d4fd017f04095a8a55/href</a></iframe><p>For convenience, let’s add a presentation controller factory.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/1a2e3948a909baeddee7ce6ebc884a34/href">https://medium.com/media/1a2e3948a909baeddee7ce6ebc884a34/href</a></iframe><p>It will be used within <em>BottomSheetTransitioningDelegate,</em></p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/019cbfe5d8a4e5421a5537960d16ebd0/href">https://medium.com/media/019cbfe5d8a4e5421a5537960d16ebd0/href</a></iframe><p>We’ll implement the factory with the dismiss handler in <em>RootViewController.</em></p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/b69494e897fa3810a1142d1b0c049426/href">https://medium.com/media/b69494e897fa3810a1142d1b0c049426/href</a></iframe><p>Now let’s configure a shadow with dismiss handler in the presentation controller. We should add the shadow before the transition starts and remove it when it’s over.</p><p>To begin, we need to track the presentation controller’s state. Let’s introduce a state property, which will be in charge of the Bottom Sheet’s current state. Let’s override the transition lifecycle methods and change state accordingly.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/70399f35852a440088dbac4c7cc2e4c0/href">https://medium.com/media/70399f35852a440088dbac4c7cc2e4c0/href</a></iframe><p>Then the question arises as to when we should add and remove the shadow. We’ll add the shadow before presenting the bottom sheet, and remove it right after it’s hidden</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/43aa19e0f285c8fa489559e656d14778/href">https://medium.com/media/43aa19e0f285c8fa489559e656d14778/href</a></iframe><p>Finally, we can implement <em>addSubviews()</em> and <em>removeSubviews()</em></p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/f6f3b99c4e4315bc72e6ee38576be1ee/href">https://medium.com/media/f6f3b99c4e4315bc72e6ee38576be1ee/href</a></iframe><p>Now, let’s see how it works.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TpNeBPtiUOOkVoGFsAiSnQ.gif" /></figure><p>Great! Now we got the shadow, and the bottom sheet’s dismissed by tap on it! But the shadow appears and disappears without any animation.</p><p><strong>Animated transition</strong></p><p>What should we do? The shadow relates to the transition, so it should be animated along. Therefore, we need to inline it into the transitioning delegate.</p><p>Let’s implement the <a href="https://developer.apple.com/documentation/uikit/uiviewcontrolleranimatedtransitioning">UIViewControllerAnimatedTransitioning</a> protocol the same way iOS already does it, but we’ll also include a fade-animation for the shadow.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/ead9211df758eac84bcb94c992ef0a3b/href">https://medium.com/media/ead9211df758eac84bcb94c992ef0a3b/href</a></iframe><p>Also, don‘t forget to implement the relevant methods in the <em>BottomSheetTransitioningDelegate</em>.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/6ee325410351e69b1428a1ce2c968f98/href">https://medium.com/media/6ee325410351e69b1428a1ce2c968f98/href</a></iframe><p>Let’s make sure that the animation works.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fFR8k6lLyee7dDCNiLiEJg.gif" /></figure><p>And it does! To finish our bottom sheet, we need to round the top corners.</p><p><strong>Rounding the corners</strong></p><p>We can make it via <em>presentedViewController’s cornerRadius</em> in the presentation controller. It needs to be done before the transition starts in <em>presentationTransitionWillBegin().</em></p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/4f82a90676691b0601761f217621843c/href">https://medium.com/media/4f82a90676691b0601761f217621843c/href</a></iframe><p>Finally, let’s have a look at the corners.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2SKzna5rxM_QZ2kDKs9hXg.gif" /></figure><p><strong>What we’ve accomplished so far:</strong></p><ol><li>We overridden the system transitioning delegate.</li><li>We created a presentation controller.</li><li>We added a shadow to hide the bottom sheet via the dismiss handler.</li><li>We implemented an animated transition via the transitioning delegate.</li><li>We made the basic design.</li></ol><h3><strong>Part 2. Interactive Bottom Sheet dismissal</strong></h3><p>Like in the first part, let’s begin with the <a href="https://github.com/joomcode/BottomSheet/tree/feature/part-2">starter project</a>. A pull bar has been added to indicate that the bottom sheet can be closed by the swipe-down gesture. Also, a scrollView has appeared full screen in ResizeViewController. We will need it for list-based screens. The rest is from part one.</p><p>Let’s see the application.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0nqziQR0EVBABstiZ0fNXg.gif" /></figure><p><strong>Theory. Specifics of Interactive dismissal</strong></p><p><a href="https://developer.apple.com/documentation/uikit/uiswipegesturerecognizer">UISwipeGestureRecognizer</a> is an obvious choice for detecting swipe gestures. It will initiate the bottom sheet dismissal.</p><p>But what if the presented controller already has this gesture? It may cause a conflict of gestures since it’s not clear which one to process first.</p><p>Is it common for the presented controller to have this gesture? In fact, all the time. In modern applications, 99 % of screens are list-based, which means that each one has a <em>UIScrollView</em> or its descendants, <em>UITableView</em> or <em>UICollectionView</em>, which have exactly the same gesture. What should we do?</p><p>Let’s break it down into two cases, without and with <em>UIScrollView</em>.</p><ol><li>If there’s no <em>UIScrollView</em>, we simply add a swipe gesture.</li><li>If it’s available, the <strong>content may fit:</strong></li></ol><ul><li><strong>Completely.</strong> Then the bottom sheet’s size will be less than the screen, so it can be closed with a swipe right away.</li><li><strong>Partially.</strong> Then a swipe may also mean content scrolling. Let’s assume that the user scrolls to close the bottom sheet when <em>contentOffset </em>is <em>zero. Otherwise,</em> their intention is content-scrolling.</li></ul><p>If <em>UIScrollView</em> exists, we subscribe to contentOffset<em> </em>changes and judge by them when interactive dismissing can be started.</p><p>Now that we have the plan, let’s implement it!</p><p><strong>If there is no UIScrollView</strong></p><p>Then we add the pan gesture to <em>presentedView</em>. At what point should we do this? The gesture initiates interactive dismissing and the bottom sheet can be closed only if it’s fully appeared. So it makes sense to add the gesture at the end of the presentation<em>.</em></p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/6e8c2d0e584b3a505dc732eaa0182049/href">https://medium.com/media/6e8c2d0e584b3a505dc732eaa0182049/href</a></iframe><p>And write the function that adds the pan gesture to the given view.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/4aa9d4e6405c97e9f4149d6d48ff9ff9/href">https://medium.com/media/4aa9d4e6405c97e9f4149d6d48ff9ff9/href</a></iframe><p>Let’s break down each state of the gesture.</p><p><strong>began</strong> — the user has just started sliding the finger, and the gesture has been recognised as the pan gesture. We initiate the bottom sheet dismissing.</p><p><strong>changed</strong> — the user continuously slides the finger across the screen. We hide the bottom sheet proportionally to the distance the user’s finger has travelled across the screen.</p><p><strong>ended</strong> — the user hass taken fingers off the screen. We decide if we dismiss the bottom sheet or return it to its original position.</p><p><strong>cancelled</strong> — the gesture is cancelled. We return the bottom sheet to its original position.</p><p>Additionally, we will use <a href="https://developer.apple.com/documentation/uikit/uipercentdriveninteractivetransition/">UIPercentDrivenInteractiveTransition</a> to pass the transition state to the transitioning delegate.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/e7fba160a2fbb96acc40c4e21bbb7d5d/href">https://medium.com/media/e7fba160a2fbb96acc40c4e21bbb7d5d/href</a></iframe><p>Let’s start with the <strong>began</strong> state. This is the right moment to initialize interactive dismissing because this state occurs only once. We also call dismiss from <em>presentingViewController</em> to notify UIKit of the intention to close the bottom sheet.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/f016d7e52b1fd06890c57c4faa9c7472/href">https://medium.com/media/f016d7e52b1fd06890c57c4faa9c7472/href</a></iframe><p>Now let’s proceed with the <strong>changed</strong> state. Change the position of <em>presentedView</em> proportionally to the distance travelled by the user’s fingertip across the screen. We measure the distance from the starting point, where the gesture began, to the current position. Next, we calculate transition progress relative to the height of the content view controller, i.e.,<em> presentedView.</em></p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/66394534feedcbf402d96a006e69f86f/href">https://medium.com/media/66394534feedcbf402d96a006e69f86f/href</a></iframe><p>Once the user takes their finger off the screen, the gesture switches to the <strong>ended</strong> state. We need to figure out what the user wants — to scroll the content or to dismiss the bottom sheet. If the user moved their finger abruptly, travelling a very short distance, they most likely wanted to dismiss the bottom Sheet. Another matter is if the user’s finger travelled a long way across the screen and, at the very last moment, sharply took their finger off the screen, aiming upwards. In this case, the bottom sheet should return to its original position. This suggests the idea of calculating some kind of movement momentum that would account for both acceleration and direction.</p><p>A bit of physics. Imagine that there is a body that moves with a constant velocity <strong><em>v₀</em></strong>. Then, at a distance <strong><em>x₀</em></strong>, it comes under the influence of a deceleration <strong><em>a</em></strong>. The question is, where will the body stop?</p><p>The velocity formula is <em>v(t)=v₀﹢a﹡t</em></p><p>With negative acceleration the velocity becomes zero, let’s denote this moment in time as <em>t₁</em> and put<em> </em>it in the velocity formula:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/264/1*acM3exfwoKaF_c-wUz3gUA.png" /></figure><p>Next let’s put<em> t₁ </em>in the distance formula <em>x(t) = x + v₀ ﹡ t + a ﹡ t²/2</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/258/1*rSLnCr8_sYwG36k_altcPQ.png" /></figure><p>Then, we finally get the formula for the distance <em>x₀−0.5 ﹡ v₀²/a</em>, where <em>x₀</em> is the body’s initial position, <em>v₀</em> is its initial velocity and <em>a</em> is the deceleration.</p><p>Let’s assume that Bottom Sheet is the body from the problem above when the gesture is over. Then, the current speed and the travelled distance can be found through pan gesture. Let’s assume the deceleration to be a constant of 800. As we know the distance formula is <em>x₀−0.5 ﹡ v₀²/a</em>, so we can calculate where the bottom sheet will stop affected by deceleration. If the stopping point is closer to the starting point, we cancel the transition, otherwise we carry it out to the end.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/400b73f677d3c47e192f72bbd0ad6013/href">https://medium.com/media/400b73f677d3c47e192f72bbd0ad6013/href</a></iframe><p>If the gesture is <strong>cancelled</strong>, we return to the starting point.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/7dc5410b054e2f0cdb928b4cbd508fa9/href">https://medium.com/media/7dc5410b054e2f0cdb928b4cbd508fa9/href</a></iframe><p>Finally, we should return <em>interactiveTransitioning to the</em> transitioning delegate.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/749a09a2cf15b062d5bff2d19cb344d2/href">https://medium.com/media/749a09a2cf15b062d5bff2d19cb344d2/href</a></iframe><p>Now let’s run the application and see what happens.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*i6c-LlUoW85110TetOY_4w.gif" /></figure><p>Woohoo, we’ve taught our bottom sheet to close on a swipe down! However, if the content is larger than the screen, scrollView will intercept the gestures.</p><p><strong>The list-based screens break down</strong></p><p>Even though <em>ResizeViewController</em> was already a list-based type, it didn’t prevent us from adding a pan gesture. It’s because scrollView has no scroll when <em>contentSize </em>is equal to its size.</p><p>Therefore, let’s consider the opposite case when <em>contentSize</em> is larger than the bottom sheet, and scrolling works. First of all, we should subscribe to <em>contentOffset</em>. Then, if <em>contentOffset</em> is zero and the user is scrolling down, then we initiate dismissing. When the user releases their finger from the screen, we’ll either cancel or finalise the transition, just like we did before. If <em>contentOffset</em> is<em> </em>changed and the user does not touch the screen, then scrolling goes on by inertia and we shouldn’t do anything.</p><p>Firs of all, we need an indicator which would tell us whether a view controller has a scrollView. Let’s introduce a protocol for this.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/b21d9623d7ca6c4f26d46553a39ad3ce/href">https://medium.com/media/b21d9623d7ca6c4f26d46553a39ad3ce/href</a></iframe><p>To track <em>contentOffset</em> changes we subscribe to UIScrollViewDelegate. But what if someone has already subscribed to <em>UIScrollView</em> delegate? Then we’ll overwrite the previous <em>delegate.</em></p><p>So we will use <a href="https://github.com/joomcode/BottomSheet/blob/main/Sources/BottomSheetUtils/JMMulticastDelegate.m">MulticastDelegate</a> to <a href="https://github.com/joomcode/BottomSheet/blob/main/Sources/BottomSheet/Helpers/Utils/UIScrollView%2BMulticastDelegate.swift">forward UIScrollView</a>’s delegate invocations to all interested parties and not just one. In a classic iOS delegate pattern, only one object at a time can subscribe, and it’s not really convenient. That’s why <em>MulticastDelegate</em> is introduced. By using it, firstly, we ensure that the delegate property is not overwritten. Secondly, we proxy all invocations to subscribers, and such an approach doesn’t require any changes in the existing codebase. It’s implemented in Objective-C because of its runtime capabilities and ability to use message dispatching. Though we could use Swift, it would be much more verbose and less universal — for each type with delegate new instance should be implemented with all the methods.</p><p>We subscribe to <em>delegate</em> after the end of the transition, just like we did with the pan gesture.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/b2de26d4783534a6ff5e930f79d64532/href">https://medium.com/media/b2de26d4783534a6ff5e930f79d64532/href</a></iframe><p>Then we should implement <em>UIScrollViewDelegate</em>. In the first place, let’s make some helpers</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/61d4898495606b930c61fddfdd3691d6/href">https://medium.com/media/61d4898495606b930c61fddfdd3691d6/href</a></iframe><p>Next, let’s consider the moment when the user is about to scroll the content. At this moment, the user has just started a swipe gesture. Remember this state with the <em>isDragging</em> flag.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/6d4ea8676058d9d86aa90fb667b7d477/href">https://medium.com/media/6d4ea8676058d9d86aa90fb667b7d477/href</a></iframe><p>Next, let’s make the helper function, which will help us to determine whether transition progress needs to be started or carried on.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/4eafe1068b8c33f46858ce9d228e3fac/href">https://medium.com/media/4eafe1068b8c33f46858ce9d228e3fac/href</a></iframe><p>We check that the user is now sliding the finger across the screen. If so, we check if the transition should be carried on.</p><p>If the transition hasn’t started or has been just initiated, it should be continued if the user scrolls down and the content is at the top (check it via <em>isContentOriginInBounds</em>). If we’re in the middle of the dismissal, we just continue the transition.</p><p>Then let’s see when <em>contentOffset</em> is changed in <em>scrollViewDidScroll(:_).</em></p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/a732bf22d0e907f96d05a61a7eedde5d/href">https://medium.com/media/a732bf22d0e907f96d05a61a7eedde5d/href</a></iframe><p><em>startInteractiveTransitionIfNeeded()</em> will initiate the interactive transition if we haven’t already done so.</p><p>In <em>scrollViewDidScroll(_:)</em> we’re checking whether we can continue/start the interactive transition. If we can start or continue, we initiate the transition. If we can’t, we should remember the last content offset before the transition was activated. We’ll need it later.</p><blockquote>💡 Remember how you were asked during interviews when a View’s bounds origin is not zero? And you probably replied: When the content offset of scrollView is non-zero. But it is not clear how this could be used in practice, is it? Below you will see how having this knowledge may be handy!</blockquote><p>Now let’s make sure that content is tied to the top and equate <em>contentInset to contentOffset</em>. We change <em>contentOffset</em> via <em>bounds.origin </em>to avoid <em>scrollViewDidScroll(_:)</em> invocation. In the end, we update the transition progress.</p><p>Once the user takes the finger off the screen after scrolling, we should finalise or cancel the transition, which is the same as we did when the pan gesture is in the <strong>ended </strong>state.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/0e8e8929dcd0d3536bcd43fae56db414/href">https://medium.com/media/0e8e8929dcd0d3536bcd43fae56db414/href</a></iframe><p>Via <em>didStartDragging</em>, we check <strong>if the bottom sheet interactive dismissal</strong> <strong>was active</strong> before the end of scrolling.</p><p><strong>If yes</strong>, then, just like with pan gesture, we use the impulse to decide if we cancel or finalise the transition.</p><p><strong>If not</strong>, we cancel the transition. It’s also possible that the user started dismissing the bottom sheet and then returned to content scrolling. In this case, the transition’s progress is zero, and we definitely want to cancel it.</p><p>Now let’s see what we’ve got.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6kfBuq_0RxCM520_-AU_Mw.gif" /></figure><p>So, we’ve learned to work with all Bottom Sheet sizes.</p><p><strong>What we’ve achieved in the second part</strong></p><ol><li>We’ve made truly interactive transition using pan gesture and scrollView.</li><li>We’ve implemented multi-subscriptions pattern to any <em>delegate </em>via<em> MulticastDelegate.</em></li></ol><h3><strong>Part 3. Maintaining UINavigationController</strong></h3><p>F inal <a href="https://github.com/joomcode/bottomsheet/tree/feature/part-3">starter project</a> is already waiting for you. Two new buttons have been added to <em>ResizeViewController</em>, which are visible if a navigation controller is present. The first one pushes <em>ResizeViewController</em> with the current content height, and the second one pops to <em>rootViewController</em>. The rest is from part two.</p><p>In part three, our goal is to support the navigation controller inside the bottom sheet with standard push and pop operations, preserving interactive pop.</p><p><strong>Theory. Will it work out of the box?</strong></p><p>Can I use the system <em>UINavigationController</em> directly? Unfortunately, no.</p><p>The navigation controller does not fully support <em>preferredContentSize</em>. The original content size and its increase work as expected. But the navigation controller does not react to its downsizing. When tapping -100, the size doesn’t change.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*evM6F7hdMEi0mgESynGEJw.gif" /><figcaption>System navigation controller behavior</figcaption></figure><p>So we will definitely need a subclass of <em>UINavigationController, </em>capable of tracking changes in the navigation stack and updating its own <em>preferredContentSize</em> according to <em>topViewController</em>.</p><p>When tracking scrollView in the presentation controller, we need to take into account that <em>presentedViewController</em> can be <em>UINavigationController</em>. Additionally, when changing the navigation stack, we need to extract <em>scrollView</em> from the current <em>topViewController</em>, if there is one.</p><p>And the final note. <em>UINavigationController</em> is packed within an iOS SDK version with its own features. As we will see later, these features will come to light and become a nudge. We will discuss what we can do about it later.</p><p><strong>Adapting to content size</strong></p><p>We’ve already made content size adaptation in the first part, but it doesn’t work with a navigation controller. The system navigation controller doesn’t fully respect <em>preferredContentSize</em> changes. So let’s create a descendant of <em>UINavigationController</em> and use the <em>UIContentContainer</em> feature.</p><p>In <em>updatePreferredContentSize, w</em>e account for <em>topViewController</em> and <em>additionalSafeAreaInsets.</em></p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/5c846ca6924da3ef29b0ac1f6b6e7bb2/href">https://medium.com/media/5c846ca6924da3ef29b0ac1f6b6e7bb2/href</a></iframe><p>Similar to the presentation controller, we react to content size changes via <em>preferredContentSizeDidChange(forChildContentContainer:)</em>. Remember that we should take care of the animation ourselves when changing <em>preferredContentSize</em>. So let’s add animation to the navigation controller<em> </em>and remove it from<em> ResizeViewController.</em></p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/28e6d28131e4d9bcfd1c7413b62a2a0d/href">https://medium.com/media/28e6d28131e4d9bcfd1c7413b62a2a0d/href</a></iframe><p>In the presentation controller, we should bear in mind that the <em>presentedViewController</em> can be a <em>UINavigationController</em>. In this case, we need to track scrollView inside the current <em>topViewController</em>. Let’s update <em>setupScrollTrackingIfNeeded()</em> accordingly.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/8effe22b0038c7d6f011a6535a63bfa7/href">https://medium.com/media/8effe22b0038c7d6f011a6535a63bfa7/href</a></iframe><p>To track changes in the navigation stack we subscribe to <em>delegate</em> via the same <em>MulticastDelegate</em> pattern we did before. We’ll keep an eye on scrollView when presenting the view controller.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/effd738cb72979ca0dc731e0c7190d3d/href">https://medium.com/media/effd738cb72979ca0dc731e0c7190d3d/href</a></iframe><p>Let’s check the result.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5I6osrzmWz4H7P10YDarJw.gif" /><figcaption>ScrollView’s tracked for each topViewController</figcaption></figure><p>The navigation controller is now responsive to changes in content size, but when switching back, the size is not involved in the animation.</p><p><strong>Animating the transition push and pop</strong></p><p>Why does it happen? Because the system implementation of the navigation controller fails to account for <em>preferredContentSize</em> when changing the navigation stack. Therefore, we need to update content size every time changes are made to the navigation stack.</p><p>With this in mind, let’s introduce a helper function for updating the stack and <em>preferredContentSize</em> together. Where possible, we resize content with an animation via <a href="https://developer.apple.com/documentation/uikit/uiviewcontroller/1619294-transitioncoordinator">transitionCoordinator</a>. It is vital to update the stack first and only then the content size. Otherwise, <em>topViewController</em> will not be up to date.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/1b36e7dc1fe5a77b26c41dab591f7aa2/href">https://medium.com/media/1b36e7dc1fe5a77b26c41dab591f7aa2/href</a></iframe><p>Finally, let’s implement <em>UINavigationController’s</em> methods that change the navigation stack via<em> updateNavigationStack(animated:applyChanges:).</em></p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/d1de5a194d39a9a9d5ddd58818b162cd/href">https://medium.com/media/d1de5a194d39a9a9d5ddd58818b162cd/href</a></iframe><p>Now let’s run the application and see what we’ve got.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*r0QnSX5crjIeTujk_zPtNQ.gif" /><figcaption>iOS 15+</figcaption></figure><p>It got better and the content size is now considered, though with some artifacts. Let’s have a look at iOS 12.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qAXYCj9yOH8QIomdlvZrsg.gif" /><figcaption>iOS 12.5.4</figcaption></figure><p>Transition quality has worsened, and the content size is the same as the previous one after the pop.</p><p>It turns out that we can’t fully count on the system transition of <em>UINavigationController</em>, and we also have to implement it by ourselves. Let’s implement <em>UINavigationControllerDelegate</em>, in which we redefine the push and pop transitions.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/b1dee03c3609dab2f5ca6a4515c52e89/href">https://medium.com/media/b1dee03c3609dab2f5ca6a4515c52e89/href</a></iframe><p>Next, let’s implement <em>UINavigationControllerDelegate</em> and define the transition’s animation.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/2a32bb112a787ab1aa61c72660758dc6/href">https://medium.com/media/2a32bb112a787ab1aa61c72660758dc6/href</a></iframe><p>Let’s run the application and pay attention to the animations.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DUCEvOTW6RfUwjz3iAzGeQ.gif" /></figure><p>Everything works smoothly, as we expected!</p><p><strong>Interactive pop</strong></p><p>When we overrode push and pop transitions, we lost the system interactive pop transition, which had previously functioned out of the box. So we’ll have to implement it by ourselves.</p><p>But how do we replicate the system interactive pop transition? Let’s see what we have in UIKit… <em>UIScreenEdgePanGestureRecognizer</em>! It’s used by iOS for exactly this kind of gesture.</p><p>We’ll add this gesture for the view controller, which has been pushed into the navigation controller’s hierarchy. Below, I’m giving only critical segments of the code.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/ce8f06196def22533c19d8eb3f19f000/href">https://medium.com/media/ce8f06196def22533c19d8eb3f19f000/href</a></iframe><p>In the handler, we do the same as for the pan gesture. Then we initiate the interactive transition, updating progress proportionally to finger movement, and use the same criterion for cancelling and finalising the transition.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/bf6e0e608ad72293088a0e805e23c0bc/href">https://medium.com/media/bf6e0e608ad72293088a0e805e23c0bc/href</a></iframe><p>What remains is to account for the interactive transition in <em>UINavigationControllerDelegate</em>. We configure an interactive pop gesture on push operation.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/4fa7ff26daf63bd83654a32a11eb75af/href">https://medium.com/media/4fa7ff26daf63bd83654a32a11eb75af/href</a></iframe><p>Let’s check it now.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*C8Bzj7q34YUXFy-ttBs11w.gif" /></figure><p>And yay, we’ve made it through our last challenge!</p><p><strong>Let’s summarise what we’ve done in the last part:</strong></p><ol><li>We’ve adapted <em>UINavigationController</em> to content size.</li><li>We’ve achieved the right push and pop transitions in iOS 12+.</li><li>We’ve kept a unique interactive pop.</li></ol><h3><strong>Conclusion</strong></h3><p>Thank you for reading to the end! Now you can update your resume. We started from scratch and ended up with a <a href="https://github.com/joomcode/BottomSheet">multifunctional bottom sheet</a>. We answered the questions we asked at the beginning of the story, and even recalled school physics!</p><p>I hope you found this helpful and now the bottom sheet in your app will play out in fresh colours!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=400515255829" width="1" height="1" alt=""><hr><p><a href="https://medium.com/joomtech/bottom-sheet-shall-we-drop-the-formalities-400515255829">Bottom Sheet, shall we drop the formalities?</a> was originally published in <a href="https://medium.com/joomtech">JoomTech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>